bucket = $bucket; $self->requestUrl = $self->getBucketRequestUrl('?max-keys=1'); $self->httpMethod = 'GET'; } return $self->testConnection(); } public static function checkBucketExistence(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', string $region = 'us-east-1') { $validation = self::validateRequestInputs($region, $bucket); if (is_wp_error($validation)) { return $validation; } $self = new static($secret, $accessKey, $sessionToken, $region); $self->bucket = $bucket; $self->requestUrl = $self->getBucketRequestUrl('?max-keys=1'); $self->httpMethod = 'GET'; // Use a simplified test connection that doesn't check public access settings return $self->testBucketConnection(); } public static function updatePublicAccessBlock(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', bool $enable = true, string $region = 'us-east-1') { $validation = self::validateRequestInputs($region, $bucket); if (is_wp_error($validation)) { return $validation; } $self = new static($secret, $accessKey, $sessionToken, $region); $self->bucket = $bucket; return $self->setPublicAccessBlock($enable); } public static function updateObjectOwnership(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', bool $enforce = true, string $region = 'us-east-1') { $validation = self::validateRequestInputs($region, $bucket); if (is_wp_error($validation)) { return $validation; } $self = new static($secret, $accessKey, $sessionToken, $region); $self->bucket = $bucket; return $self->setObjectOwnership($enforce); } public static function checkSecuritySettings(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', string $region = 'us-east-1') { $validation = self::validateRequestInputs($region, $bucket); if (is_wp_error($validation)) { return $validation; } $self = new static($secret, $accessKey, $sessionToken, $region); $self->bucket = $bucket; $publicAccess = $self->checkPublicAccessBlock(); $objectOwnership = $self->checkObjectOwnership(); return [ 'block_public_access' => !is_wp_error($publicAccess) ? $publicAccess : false, 'object_ownership' => !is_wp_error($objectOwnership) ? $objectOwnership : false ]; } public function __construct(string $secret, string $accessKey, ?string $sessionToken = null, string $region = 'us-east-1') { $this->secretKey = $secret; $this->accessKey = $accessKey; $this->sessionToken = $sessionToken; $this->region = $region; $this->bucket = ''; $this->httpMethod = "GET"; $this->timeStamp = gmdate('Ymd\THis\Z'); $this->date = substr($this->timeStamp, 0, 8); $this->requestUrl = $this->getServiceListUrl(); // Signature generation happens in testConnection now or we regenerate it there if we change params } public function testConnection() { add_filter('http_request_timeout', function () { return 30; }); // Regenerate signature because request params might have changed (if bucket was set) $this->signature = $this->generateSignature(); // Ensure headers are generated with new info $headers = $this->getHeaders(); $response = wp_remote_request($this->requestUrl, [ 'method' => $this->httpMethod, 'headers' => $headers ]); $responseCode = wp_remote_retrieve_response_code($response); // Success check if ($responseCode >= 200 && $responseCode < 300) { $data = [ 'message' => __('Successfully verified S3 connection!', 'fluent-cart'), 'code' => $responseCode, 'region' => $this->region ]; if ($this->bucket) { // Check Public Access Block $publicAccess = $this->checkPublicAccessBlock(); if (!is_wp_error($publicAccess)) { $data['block_public_access'] = $publicAccess; } // Check Object Ownership $objectOwnership = $this->checkObjectOwnership(); if (!is_wp_error($objectOwnership)) { $data['object_ownership'] = $objectOwnership; } } return $data; } // Check for Region Mismatch (301 PermanentRedirect or 400 with specific headers) $detectedRegion = wp_remote_retrieve_header($response, 'x-amz-bucket-region'); if (($responseCode == 301 || $responseCode == 400) && $detectedRegion && $detectedRegion !== $this->region) { $retryResult = $this->retryWithRegion($detectedRegion, '?max-keys=1'); if (is_wp_error($retryResult)) { return $retryResult; } return $this->testConnection(); } // Error Handling $error_message = __('Invalid S3 credentials', 'fluent-cart'); $errorCode = 'invalid_credentials'; $responseBody = wp_remote_retrieve_body($response); if (!empty($responseBody)) { $xml = simplexml_load_string($responseBody); if ($xml) { $awsCode = isset($xml->Code) ? (string)$xml->Code : ''; $awsMessage = isset($xml->Message) ? (string) $xml->Message : ''; // Check specific AWS error codes if ($awsCode === 'NoSuchBucket') { $msg = $awsMessage ?: sprintf(__('Bucket (%s) does not exist.', 'fluent-cart'), $this->bucket); return new \WP_Error('bucket_not_found', $msg); } if ($awsCode === 'PermanentRedirect') { $error_message = $awsMessage ?: __('The bucket you are attempting to access must be addressed using the specified endpoint.', 'fluent-cart'); if ($detectedRegion) { $error_message .= ' ' . sprintf(__('Correct Region: %s', 'fluent-cart'), $detectedRegion); } return new \WP_Error('region_mismatch', $error_message); } if ($awsCode === 'AccessDenied' || $responseCode == 403) { // If we are checking credentials only (no bucket), generic invalid strings if (!$this->bucket) { $error_message = $awsMessage ?: __('Invalid S3 credentials', 'fluent-cart'); } else { $error_message = $awsMessage ?: sprintf(__('Access forbidden to the configured bucket (%s). Check permissions.', 'fluent-cart'), $this->bucket); $errorCode = 'bucket_forbidden'; } } // Use AWS message if available and we haven't set a custom one if ($awsMessage && $error_message === __('Invalid S3 credentials', 'fluent-cart') && $errorCode === 'invalid_credentials') { $error_message = $awsMessage; } if (strpos($error_message, 'User:') !== false) { $error_message = __('Your IAM user does not have permission to use S3 buckets', 'fluent-cart'); } } } // If we didn't get XML or couldn't parse it, fallback to status codes if ($this->bucket) { if ($responseCode == 404) { 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)); } if ($responseCode == 403 && $errorCode === 'invalid_credentials') { // Use more specific error if we haven't already return new \WP_Error('bucket_forbidden', sprintf(__('Access forbidden to the configured bucket (%s). Check permissions.', 'fluent-cart'), $this->bucket)); } } return new \WP_Error($responseCode, $error_message); } public function testBucketConnection() { add_filter('http_request_timeout', function () { return 30; }); // Regenerate signature because request params might have changed $this->signature = $this->generateSignature(); $headers = $this->getHeaders(); $response = wp_remote_request($this->requestUrl, [ 'method' => $this->httpMethod, 'headers' => $headers ]); $responseCode = wp_remote_retrieve_response_code($response); // Success check if ($responseCode >= 200 && $responseCode < 300) { return [ 'message' => __('Successfully verified bucket!', 'fluent-cart'), 'code' => $responseCode, 'region' => $this->region ]; } // Check for Region Mismatch (301 PermanentRedirect or 400 with specific headers) $detectedRegion = wp_remote_retrieve_header($response, 'x-amz-bucket-region'); // If header is missing or we are in a redirect loop, try GetBucketLocation API if (($responseCode == 301 || $responseCode == 400) && (!$detectedRegion || $detectedRegion !== $this->region)) { // Try explicit GetBucketLocation call $locationRegion = $this->getBucketLocation(); if ($locationRegion && $locationRegion !== $this->region) { $detectedRegion = $locationRegion; } // Fallback: Try HEAD request (unsigned) which often returns region header even on 400/403 if (!$detectedRegion) { $headUrl = $this->getBucketBaseUrlForRegion(self::DEFAULT_REGION); $headResponse = wp_remote_head($headUrl); if (!is_wp_error($headResponse)) { $headRegion = wp_remote_retrieve_header($headResponse, 'x-amz-bucket-region'); if ($headRegion) { $detectedRegion = $headRegion; } } } } if (($responseCode == 301 || $responseCode == 400) && $detectedRegion && $detectedRegion !== $this->region) { $retryResult = $this->retryWithRegion($detectedRegion, '?max-keys=1'); if (is_wp_error($retryResult)) { return $retryResult; } return $this->testBucketConnection(); } // Error Handling reuse $error_message = __('Invalid S3 credentials', 'fluent-cart'); $errorCode = 'invalid_credentials'; $responseBody = wp_remote_retrieve_body($response); if (!empty($responseBody)) { $xml = simplexml_load_string($responseBody); if ($xml) { $awsCode = isset($xml->Code) ? (string)$xml->Code : ''; $awsMessage = isset($xml->Message) ? (string) $xml->Message : ''; if ($awsCode === 'NoSuchBucket') { $msg = $awsMessage ?: sprintf(__('Bucket (%s) does not exist.', 'fluent-cart'), $this->bucket); return new \WP_Error('bucket_not_found', $msg); } if ($awsCode === 'PermanentRedirect') { // Ensure we display the endpoint message if we couldn't auto-redirect $error_message = $awsMessage ?: __('The bucket you are attempting to access must be addressed using the specified endpoint.', 'fluent-cart'); // We should ideally tell the user the correct region if we know it if ($detectedRegion) { $error_message .= ' ' . sprintf(__('Correct Region: %s', 'fluent-cart'), $detectedRegion); } return new \WP_Error('region_mismatch', $error_message); } if ($awsCode === 'AccessDenied' || $responseCode == 403) { $error_message = $awsMessage ?: sprintf(__('Access forbidden to the configured bucket (%s). Check permissions.', 'fluent-cart'), $this->bucket); } if ($awsMessage && $error_message === __('Invalid S3 credentials', 'fluent-cart')) { $error_message = $awsMessage; } } } if ($responseCode == 404) { return new \WP_Error('bucket_not_found', sprintf(__('Bucket (%s) does not exist.', 'fluent-cart'), $this->bucket)); } return new \WP_Error($responseCode, $error_message); } public function checkPublicAccessBlock() { // Save current state $originalRequestUrl = $this->requestUrl; $originalHttpMethod = $this->httpMethod; $originalContentMD5 = $this->contentMD5; // Set up for GET request (read public access block) $this->requestUrl = $this->getBucketRequestUrl('/?publicAccessBlock'); $this->httpMethod = 'GET'; $this->contentMD5 = null; // No body for GET request // Regenerate signature because request params changed $this->signature = $this->generateSignature(); // Generate headers with new signature $headers = $this->getHeaders(); $response = wp_remote_request($this->requestUrl, [ 'method' => $this->httpMethod, 'headers' => $headers ]); if (is_wp_error($response)) { return $response; } // Restore original values $this->requestUrl = $originalRequestUrl; $this->httpMethod = $originalHttpMethod; $this->contentMD5 = $originalContentMD5; $responseCode = wp_remote_retrieve_response_code($response); if ($responseCode >= 200 && $responseCode < 300) { $body = wp_remote_retrieve_body($response); $xml = simplexml_load_string($body); // AWS returns root element // $xml IS the configuration object, not a wrapper containing it. if ($xml) { // Check if all block settings are enabled $blockPublicAcls = (string)$xml->BlockPublicAcls === 'true'; $ignorePublicAcls = (string)$xml->IgnorePublicAcls === 'true'; $blockPublicPolicy = (string)$xml->BlockPublicPolicy === 'true'; $restrictPublicBuckets = (string)$xml->RestrictPublicBuckets === 'true'; // If any is true, we can consider it as having some blocking, but usually "Block All" means all are true. return $blockPublicAcls && $ignorePublicAcls && $blockPublicPolicy && $restrictPublicBuckets; } } else if ($responseCode == 404) { // 404 on ?publicAccessBlock means no configuration exists, so it's disabled. return false; } return new \WP_Error('public_access_check_failed', __('Could not check public access settings', 'fluent-cart')); } public function setPublicAccessBlock(bool $enable) { // Save current requestUrl and method $originalRequestUrl = $this->requestUrl; $originalHttpMethod = $this->httpMethod; $this->requestUrl = $this->getBucketRequestUrl('/?publicAccessBlock'); $this->httpMethod = 'PUT'; $setting = $enable ? 'true' : 'false'; $this->payloadBody = << {$setting} {$setting} {$setting} {$setting} XML; $this->signature = $this->generateSignature(); $headers = $this->getHeaders(); $headers['Content-Type'] = 'application/xml'; $response = wp_remote_request($this->requestUrl, [ 'method' => $this->httpMethod, 'headers' => $headers, 'body' => $this->payloadBody ]); if (is_wp_error($response)) { return $response; } // Restore $this->requestUrl = $originalRequestUrl; $this->httpMethod = $originalHttpMethod; $this->payloadBody = ''; $responseCode = wp_remote_retrieve_response_code($response); if ($responseCode >= 200 && $responseCode < 300) { return true; } $body = wp_remote_retrieve_body($response); $xml = simplexml_load_string($body); $errorMsg = $xml && isset($xml->Message) ? (string)$xml->Message : $body; /* translators: %1$s: error message from S3 API */ return new \WP_Error('s3_update_failed', sprintf(__('Failed to update S3 Public Access Block: %1$s', 'fluent-cart'), $errorMsg)); } public function checkObjectOwnership() { $originalRequestUrl = $this->requestUrl; $originalHttpMethod = $this->httpMethod; $originalContentMD5 = $this->contentMD5; // Set up for GET request (read ownership controls) $this->requestUrl = $this->getBucketRequestUrl('/?ownershipControls'); $this->httpMethod = 'GET'; $this->contentMD5 = null; // No body for GET request $this->signature = $this->generateSignature(); $headers = $this->getHeaders(); $response = wp_remote_request($this->requestUrl, [ 'method' => $this->httpMethod, 'headers' => $headers ]); if (is_wp_error($response)) { return $response; } // Restore original values $this->requestUrl = $originalRequestUrl; $this->httpMethod = $originalHttpMethod; $this->contentMD5 = $originalContentMD5; $responseCode = wp_remote_retrieve_response_code($response); if ($responseCode >= 200 && $responseCode < 300) { $body = wp_remote_retrieve_body($response); $xml = simplexml_load_string($body); // AWS response format: VALUE if ($xml && isset($xml->Rule->ObjectOwnership)) { $ownership = (string)$xml->Rule->ObjectOwnership; // BucketOwnerEnforced = Enforced (ACLs disabled) // BucketOwnerPreferred = ACLs enabled (usually) return $ownership === 'BucketOwnerEnforced'; } } else if ($responseCode == 404) { // If ownership controls are not found, it implies legacy behavior (ObjectWriter), which means ACLs are enabled (Not Enforced). return false; } return new \WP_Error('ownership_check_failed', __('Could not check object ownership settings', 'fluent-cart')); } public function getBucketLocation() { $originalRequestUrl = $this->requestUrl; $originalHttpMethod = $this->httpMethod; $originalContentMD5 = $this->contentMD5; // S3 API: GET /?location // This request must be signed, but standard auth works. // It returns LocationConstraint XML. $this->requestUrl = $this->getBucketRequestUrl('/?location'); $this->httpMethod = 'GET'; $this->contentMD5 = null; $this->signature = $this->generateSignature(); $headers = $this->getHeaders(); $response = wp_remote_request($this->requestUrl, [ 'method' => $this->httpMethod, 'headers' => $headers ]); // Restore $this->requestUrl = $originalRequestUrl; $this->httpMethod = $originalHttpMethod; $this->contentMD5 = $originalContentMD5; $responseCode = wp_remote_retrieve_response_code($response); if ($responseCode >= 200 && $responseCode < 300) { $body = wp_remote_retrieve_body($response); $xml = simplexml_load_string($body); if ($xml) { $location = (string)$xml ?: self::DEFAULT_REGION; return S3InputValidator::isValidRegion($location) ? $location : null; } } return null; // Could not determine } public function setObjectOwnership(bool $enforce) { $originalRequestUrl = $this->requestUrl; $originalHttpMethod = $this->httpMethod; $this->requestUrl = $this->getBucketRequestUrl('/?ownershipControls'); $this->httpMethod = 'PUT'; // BucketOwnerEnforced = Enforced (ACLs disabled) // BucketOwnerPreferred = ACLs enabled $setting = $enforce ? 'BucketOwnerEnforced' : 'BucketOwnerPreferred'; $this->payloadBody = '' . $setting . ''; // Set Content-MD5 property for signature generation $this->contentMD5 = base64_encode(md5($this->payloadBody, true)); $this->signature = $this->generateSignature(); $headers = $this->getHeaders(); // Add Content-Type header $headers['Content-Type'] = 'application/xml'; $response = wp_remote_request($this->requestUrl, [ 'method' => $this->httpMethod, 'headers' => $headers, 'body' => $this->payloadBody ]); if (is_wp_error($response)) { return $response; } $this->requestUrl = $originalRequestUrl; $this->httpMethod = $originalHttpMethod; $this->payloadBody = ''; $this->contentMD5 = null; // Reset MD5 after PUT operation $responseCode = wp_remote_retrieve_response_code($response); if ($responseCode >= 200 && $responseCode < 300) { return true; } $body = wp_remote_retrieve_body($response); $xml = simplexml_load_string($body); $errorMsg = $xml && isset($xml->Message) ? (string)$xml->Message : $body; /* translators: %1$s: error message from S3 API */ return new \WP_Error('s3_update_failed_ownership', sprintf(__('Failed to update S3 Object Ownership: %1$s', 'fluent-cart'), $errorMsg)); } private static function validateRequestInputs(string $region, string $bucket = '') { $regionValidation = S3InputValidator::validateRegion($region); if (is_wp_error($regionValidation)) { return $regionValidation; } if ($bucket !== '') { $bucketValidation = S3InputValidator::validateBucket($bucket); if (is_wp_error($bucketValidation)) { return $bucketValidation; } } return true; } private function getServiceListUrl(): string { $host = $this->region === self::DEFAULT_REGION ? 's3.amazonaws.com' : "s3.{$this->region}.amazonaws.com"; return 'https://' . $host . '/?list-type=2&encoding-type=url&max-keys=1'; } private function getBucketBaseUrlForRegion(string $region): string { if ($this->bucket === '') { return $this->getServiceListUrl(); } if (strpos($this->bucket, '.') !== false) { $host = $region === self::DEFAULT_REGION ? 's3.amazonaws.com' : "s3.{$region}.amazonaws.com"; return 'https://' . $host . '/' . $this->bucket . '/'; } $host = $region === self::DEFAULT_REGION ? "{$this->bucket}.s3.amazonaws.com" : "{$this->bucket}.s3.{$region}.amazonaws.com"; return 'https://' . $host; } private function getBucketRequestUrl(string $querySuffix = '?max-keys=1'): string { return $this->getBucketBaseUrlForRegion($this->region) . $querySuffix; } private function retryWithRegion(string $detectedRegion, string $querySuffix) { $regionValidation = S3InputValidator::validateRegion($detectedRegion); if (is_wp_error($regionValidation)) { return $regionValidation; } if ($this->regionRetryCount >= self::MAX_REGION_RETRIES) { return new \WP_Error( 'region_retry_limit', __('Unable to determine the correct S3 region after multiple attempts. Please verify the bucket region and try again.', 'fluent-cart') ); } $this->regionRetryCount++; $this->region = $detectedRegion; $this->requestUrl = $this->bucket ? $this->getBucketRequestUrl($querySuffix) : $this->getServiceListUrl(); return true; } private function generateSignature() { return hash_hmac( $this->hashAlgorithm, $this->createStringToSign(), $this->getSigningKey() ); } private function createStringToSign(): string { $hash = hash($this->hashAlgorithm, $this->createCanonicalUrl()); return "AWS4-HMAC-SHA256\n{$this->timeStamp}\n{$this->getScope()}\n{$hash}"; } private function createCanonicalUrl(): string { $canonicalQuery = "encoding-type=url&list-type=2&max-keys=1"; // Logic to construct query params correctly for different requests // If verify() was called with a bucket, we set requestUrl with ?max-keys=1 on the root of the bucket path // But if we are checking public access block, the query is different. // We need to handle this based on the current requestUrl or context. // Simplified approach: rely on the fact that for the main check, it IS max-keys=1. if ($this->bucket) { // For bucket check (GET /?max-keys=1) if (strpos($this->requestUrl, 'max-keys=1') !== false) { $canonicalQuery = "max-keys=1"; } else if (strpos($this->requestUrl, 'publicAccessBlock') !== false) { $canonicalQuery = "publicAccessBlock="; } else if (strpos($this->requestUrl, 'ownershipControls') !== false) { $canonicalQuery = "ownershipControls="; } else if (strpos($this->requestUrl, 'location') !== false) { $canonicalQuery = "location="; } } // Build canonical headers - MUST be sorted alphabetically by header name $canonicalHeaders = ""; if ($this->contentMD5) { $canonicalHeaders .= "content-md5:{$this->contentMD5}\n"; } $canonicalHeaders .= "host:{$this->getHost()}\n"; $canonicalHeaders .= "x-amz-content-sha256:{$this->getContentHash()}\n"; $canonicalHeaders .= "x-amz-date:{$this->timeStamp}\n"; if ($this->sessionToken) { $canonicalHeaders .= "x-amz-security-token:{$this->sessionToken}\n"; } // Signed headers - MUST match the order of canonical headers (alphabetically sorted) $signedHeaders = ""; if ($this->contentMD5) { $signedHeaders .= "content-md5;"; } $signedHeaders .= "host;x-amz-content-sha256;x-amz-date"; if ($this->sessionToken) { $signedHeaders .= ";x-amz-security-token"; } $path = "/"; // Default path if ($this->isPathStyle()) { $path = "/{$this->bucket}/"; } // Canonical request format per AWS Signature v4: // HTTPMethod\n // CanonicalURI\n // CanonicalQueryString\n // CanonicalHeaders\n // SignedHeaders\n // HashedPayload $payload = "$this->httpMethod\n" . "{$path}\n" . "{$canonicalQuery}\n" . "{$canonicalHeaders}\n" . "{$signedHeaders}\n" . $this->getContentHash(); return $payload; } private function getHost(): string { // Parse host from the request URL to support virtual hosted style buckets return parse_url($this->requestUrl, PHP_URL_HOST) ?: "s3.amazonaws.com"; } private function getContentHash(): string { return hash($this->hashAlgorithm, $this->payloadBody); } private function getScope(): string { return "{$this->date}/{$this->region}/s3/aws4_request"; } private function getSigningKey() { $dateKey = hash_hmac($this->hashAlgorithm, $this->date, "AWS4{$this->secretKey}", true); $regionKey = hash_hmac($this->hashAlgorithm, $this->region, $dateKey, true); $serviceKey = hash_hmac($this->hashAlgorithm, 's3', $regionKey, true); return hash_hmac($this->hashAlgorithm, 'aws4_request', $serviceKey, true); } private function getHeaders(): array { $headers = [ "x-amz-content-sha256" => $this->getContentHash(), 'x-amz-date' => $this->timeStamp, ]; if ($this->contentMD5) { $headers['content-md5'] = $this->contentMD5; } if ($this->sessionToken) { $headers['x-amz-security-token'] = $this->sessionToken; } $signedHeaders = ""; if ($this->contentMD5) { $signedHeaders .= "content-md5;"; } $signedHeaders .= "host;x-amz-content-sha256;x-amz-date"; if ($this->sessionToken) { $signedHeaders .= ";x-amz-security-token"; } $headers['Authorization'] = "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders={$signedHeaders}, Signature={$this->signature}"; return $headers; } private function isPathStyle() { return $this->bucket && strpos($this->bucket, '.') !== false; } }