uploadWithRetry( $filePath, $cloudPath, $provider, $credentials, 'simple' ); } return $this->uploadWithRetry( $filePath, $cloudPath, $provider, $credentials, 'multipart' ); } /** * Download an object from the cloud to a local file. Symmetric to upload(). * Used by the restore flow. */ public function download(string $cloudPath, string $localPath, string $provider): string { $credentials = Config::getCloudCredentials(); if (!$credentials) { throw new \RuntimeException('Cloud credentials not configured'); } $endpoint = $this->getEndpoint($provider, $credentials); $bucket = $credentials['bucket_name'] ?? $credentials['bucket'] ?? ''; $keyId = $credentials['key_id'] ?? $credentials['access_key_id'] ?? ''; $appKey = $credentials['app_key'] ?? $credentials['secret_access_key'] ?? ''; $region = $credentials['region'] ?? 'us-west-004'; $url = $endpoint . '/' . $bucket . '/' . ltrim($cloudPath, '/'); $date = gmdate('Ymd\THis\Z'); $payloadHash = hash('sha256', ''); $headers = [ 'Host' => parse_url($endpoint, PHP_URL_HOST), 'x-amz-date' => $date, 'x-amz-content-sha256' => $payloadHash, ]; $headers['Authorization'] = $this->signRequest('GET', $url, $headers, $keyId, $appKey, $region, $payloadHash); $response = wp_remote_get($url, [ 'headers' => $headers, 'timeout' => 600, 'stream' => true, 'filename' => $localPath, ]); if (is_wp_error($response)) { throw new \RuntimeException('Download failed: ' . $response->get_error_message()); } $code = wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 300) { throw new \RuntimeException('Download failed: ' . self::refusal($response)); } if (!file_exists($localPath) || filesize($localPath) === 0) { throw new \RuntimeException('Downloaded archive is empty: ' . $cloudPath); } Logger::info('restore', "Downloaded: {$cloudPath}"); return $localPath; } /** * Size in bytes of a stored object, or null when it is absent or unreadable. * * A signed HEAD. This is what catches the failure mode that gives this project its * first documented pitfall: addressed without path-style URLs, Backblaze B2 answers * a silent 301 and the object lands nowhere — while the upload reports success. A * completed backup pointing at an object that does not exist is worse than a failed * one, because nobody goes looking for it until a restore. */ public function size(string $cloudPath, string $provider): ?int { $credentials = Config::getCloudCredentials(); if (!$credentials) { return null; } $endpoint = S3Endpoint::fromCredentials($provider, $credentials); $url = $endpoint->objectUrl($cloudPath); $emptyHash = hash('sha256', ''); $headers = [ 'Host' => $endpoint->host(), 'x-amz-date' => S3Signer::timestamp(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))), 'x-amz-content-sha256' => $emptyHash, ]; $headers['Authorization'] = $this->signRequest( 'HEAD', $url, $headers, (string) ($credentials['key_id'] ?? $credentials['access_key_id'] ?? ''), (string) ($credentials['app_key'] ?? $credentials['secret_access_key'] ?? ''), $endpoint->region, $emptyHash ); $response = wp_remote_request($url, ['method' => 'HEAD', 'headers' => $headers, 'timeout' => 30]); // The three outcomes must stay distinguishable, because the caller acts very // differently on each: a 404 is PROOF the object is not there (fail the backup), // while an unreachable endpoint proves nothing (warn, keep the backup). if (is_wp_error($response)) { throw new \RuntimeException('HEAD injoignable : ' . $response->get_error_message()); } $code = (int) wp_remote_retrieve_response_code($response); if ($code === 404) { return null; } if ($code !== 200) { throw new \RuntimeException('HEAD refusé : ' . self::refusal($response)); } $length = wp_remote_retrieve_header($response, 'content-length'); if ($length === '' || $length === null) { throw new \RuntimeException('HEAD sans Content-Length : taille indéterminable'); } return (int) $length; } public function delete(string $cloudPath, string $provider): bool { $credentials = Config::getCloudCredentials(); if (!$credentials) { return false; } $endpoint = $this->getEndpoint($provider, $credentials); $bucket = $credentials['bucket_name'] ?? $credentials['bucket'] ?? ''; $keyId = $credentials['key_id'] ?? $credentials['access_key_id'] ?? ''; $appKey = $credentials['app_key'] ?? $credentials['secret_access_key'] ?? ''; $region = $credentials['region'] ?? 'us-west-004'; $url = $endpoint . '/' . $bucket . '/' . ltrim($cloudPath, '/'); $date = gmdate('Ymd\THis\Z'); $headers = [ 'Host' => parse_url($endpoint, PHP_URL_HOST), 'x-amz-date' => $date, 'x-amz-content-sha256' => hash('sha256', ''), ]; $auth = $this->signRequest('DELETE', $url, $headers, $keyId, $appKey, $region, hash('sha256', '')); $headers['Authorization'] = $auth; $response = wp_remote_request($url, [ 'method' => 'DELETE', 'headers' => $headers, 'timeout' => 30, ]); return !is_wp_error($response) && in_array(wp_remote_retrieve_response_code($response), [200, 204], true); } private function uploadWithRetry( string $filePath, string $cloudPath, string $provider, array $credentials, string $strategy ): array { $lastException = null; for ($attempt = 0; $attempt <= self::RETRIES; $attempt++) { try { if ($strategy === 'multipart') { return $this->doMultipartUpload($filePath, $cloudPath, $provider, $credentials); } return $this->doSimpleUpload($filePath, $cloudPath, $provider, $credentials); } catch (\Throwable $e) { $lastException = $e; Logger::warning('backup', "Upload attempt {$attempt} failed: " . $e->getMessage()); if ($attempt < self::RETRIES) { sleep(self::BACKOFF_SECONDS[$attempt] ?? 9); } } } throw new \RuntimeException( 'Upload failed after ' . (self::RETRIES + 1) . ' attempts: ' . $lastException->getMessage(), 0, $lastException ); } private function doSimpleUpload(string $filePath, string $cloudPath, string $provider, array $credentials): array { $endpoint = $this->getEndpoint($provider, $credentials); $bucket = $credentials['bucket_name'] ?? $credentials['bucket'] ?? ''; $keyId = $credentials['key_id'] ?? $credentials['access_key_id'] ?? ''; $appKey = $credentials['app_key'] ?? $credentials['secret_access_key'] ?? ''; $region = $credentials['region'] ?? 'us-west-004'; $contentType = 'application/gzip'; // Compute SHA-256 hash in streaming to avoid loading large files into memory $payloadHash = $this->hashFileSha256($filePath); $date = gmdate('Ymd\THis\Z'); $url = $endpoint . '/' . $bucket . '/' . ltrim($cloudPath, '/'); $headers = [ 'Host' => parse_url($endpoint, PHP_URL_HOST), 'Content-Type' => $contentType, 'x-amz-date' => $date, 'x-amz-content-sha256' => $payloadHash, ]; $auth = $this->signRequest('PUT', $url, $headers, $keyId, $appKey, $region, $payloadHash); $headers['Authorization'] = $auth; // Le corps est LU EN MÉMOIRE, et non passé en ressource. // // `wp_remote_request()` n'accepte qu'un `array|string` : depuis que // WordPress est passé à WpOrg\Requests, une ressource lève une TypeError // — « Argument #3 (\$data) must be of type array|string, resource given ». // L'intention d'origine (ne pas charger le fichier en mémoire) était // bonne, mais la plateforme ne sait pas streamer un corps de REQUÊTE : // `stream` n'y concerne que les réponses. // // Le coût est borné par construction : ce chemin ne s'emprunte QUE sous le // seuil multipart (20 Mo), au-delà duquel l'envoi part par tranches. Le // moteur relève d'ailleurs `memory_limit` à 1 Go avant de commencer. // // Mesuré le 27 août 2026 par le cycle e2e : la panne emportait tout envoi // d'archive sous le seuil — **donc tous les différentiels**, qui sont // petits par nature. Elle ne se voyait pas en production parce que les // seules sauvegardes qui aboutissaient étaient assez grosses pour partir // en multipart. $body = file_get_contents($filePath); if ($body === false) { throw new \RuntimeException('Cannot open file for upload: ' . $filePath); } $response = wp_remote_request($url, [ 'method' => 'PUT', 'headers' => $headers, 'body' => $body, 'timeout' => 300, ]); if (is_wp_error($response)) { throw new \RuntimeException('Upload failed: ' . $response->get_error_message()); } $code = wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 300) { throw new \RuntimeException('Upload failed: ' . self::refusal($response)); } $etag = wp_remote_retrieve_header($response, 'etag'); Logger::info('backup', "File uploaded: {$cloudPath}"); return [ 'url' => $url, 'bucket' => $bucket, 'path' => $cloudPath, 'size' => filesize($filePath), 'etag' => $etag, ]; } private function doMultipartUpload(string $filePath, string $cloudPath, string $provider, array $credentials): array { $endpoint = $this->getEndpoint($provider, $credentials); $bucket = $credentials['bucket_name'] ?? $credentials['bucket'] ?? ''; $keyId = $credentials['key_id'] ?? $credentials['access_key_id'] ?? ''; $appKey = $credentials['app_key'] ?? $credentials['secret_access_key'] ?? ''; $region = $credentials['region'] ?? 'us-west-004'; $contentType = 'application/gzip'; $url = $endpoint . '/' . $bucket . '/' . ltrim($cloudPath, '/'); $date = gmdate('Ymd\THis\Z'); // Step 1: Initiate multipart upload $initHeaders = [ 'Host' => parse_url($endpoint, PHP_URL_HOST), 'Content-Type' => $contentType, 'x-amz-date' => $date, 'x-amz-content-sha256' => hash('sha256', ''), ]; $initAuth = $this->signRequest('POST', $url . '?uploads', $initHeaders, $keyId, $appKey, $region, hash('sha256', '')); $initHeaders['Authorization'] = $initAuth; $initResponse = wp_remote_request($url . '?uploads', [ 'method' => 'POST', 'headers' => $initHeaders, 'timeout' => 30, ]); if (is_wp_error($initResponse) || wp_remote_retrieve_response_code($initResponse) !== 200) { throw new \RuntimeException('Failed to initiate multipart upload: ' . self::refusal($initResponse)); } $uploadId = MultipartUpload::parseUploadId(wp_remote_retrieve_body($initResponse)); // Once the upload is initiated, any failure must abort it — otherwise the // already-uploaded parts linger on B2/S3 as orphaned, billable storage. try { // Step 2: Upload parts $handle = fopen($filePath, 'rb'); if ($handle === false) { throw new \RuntimeException('Cannot open file for multipart upload'); } $partNumber = 1; $parts = []; while (!feof($handle)) { $chunk = fread($handle, self::CHUNK_SIZE); if ($chunk === false || $chunk === '') { break; } $chunkHash = hash('sha256', $chunk); $partHeaders = [ 'Host' => parse_url($endpoint, PHP_URL_HOST), 'Content-Type' => $contentType, 'x-amz-date' => gmdate('Ymd\THis\Z'), 'x-amz-content-sha256' => $chunkHash, ]; $partUrl = $url . '?partNumber=' . $partNumber . '&uploadId=' . $uploadId; $partAuth = $this->signRequest('PUT', $partUrl, $partHeaders, $keyId, $appKey, $region, $chunkHash); $partHeaders['Authorization'] = $partAuth; $partResponse = wp_remote_request($partUrl, [ 'method' => 'PUT', 'headers' => $partHeaders, 'body' => $chunk, 'timeout' => 300, ]); if (is_wp_error($partResponse) || wp_remote_retrieve_response_code($partResponse) !== 200) { fclose($handle); throw new \RuntimeException("Part {$partNumber} upload failed: " . self::refusal($partResponse)); } $parts[] = [ 'PartNumber' => $partNumber, 'ETag' => wp_remote_retrieve_header($partResponse, 'etag'), ]; $partNumber++; } fclose($handle); // Step 3: Complete multipart upload $completeXml = MultipartUpload::completionXml($parts); $completeHeaders = [ 'Host' => parse_url($endpoint, PHP_URL_HOST), 'Content-Type' => 'application/xml', 'x-amz-date' => gmdate('Ymd\THis\Z'), 'x-amz-content-sha256' => hash('sha256', $completeXml), ]; $completeUrl = $url . '?uploadId=' . $uploadId; $completeAuth = $this->signRequest('POST', $completeUrl, $completeHeaders, $keyId, $appKey, $region, hash('sha256', $completeXml)); $completeHeaders['Authorization'] = $completeAuth; $completeResponse = wp_remote_request($completeUrl, [ 'method' => 'POST', 'headers' => $completeHeaders, 'body' => $completeXml, 'timeout' => 300, ]); if (is_wp_error($completeResponse) || wp_remote_retrieve_response_code($completeResponse) !== 200) { throw new \RuntimeException('Failed to complete multipart upload: ' . self::refusal($completeResponse)); } // S3 streams whitespace while finalising and only then writes the outcome, // so a 200 can still carry an document. Treating the status code as // proof of success is how a failed upload gets recorded as a valid backup. if (MultipartUpload::completionFailed((string) wp_remote_retrieve_body($completeResponse))) { throw new \RuntimeException( 'Multipart upload reported an error in its completion response: ' . (StorageError::reason((string) wp_remote_retrieve_body($completeResponse)) ?? 'sans motif exploitable') ); } } catch (\Throwable $e) { $this->abortMultipartUpload($url, $uploadId, $endpoint, $keyId, $appKey, $region); throw $e; } Logger::info('backup', "Multipart upload completed: {$cloudPath} (" . count($parts) . " parts)"); return [ 'url' => $url, 'bucket' => $bucket, 'path' => $cloudPath, 'size' => filesize($filePath), 'etag' => null, ]; } /** * Best-effort AbortMultipartUpload (S3/B2): frees the orphaned parts left by a * failed multipart upload so they don't accrue storage charges. Never throws — * the original upload error is what callers should see. */ private function abortMultipartUpload(string $url, string $uploadId, string $endpoint, string $keyId, string $appKey, string $region): void { try { $abortUrl = $url . '?uploadId=' . $uploadId; $emptyHash = hash('sha256', ''); $headers = [ 'Host' => parse_url($endpoint, PHP_URL_HOST), 'x-amz-date' => gmdate('Ymd\THis\Z'), 'x-amz-content-sha256' => $emptyHash, ]; $headers['Authorization'] = $this->signRequest('DELETE', $abortUrl, $headers, $keyId, $appKey, $region, $emptyHash); wp_remote_request($abortUrl, [ 'method' => 'DELETE', 'headers' => $headers, 'timeout' => 30, ]); Logger::warning('backup', "Multipart upload aborted to free orphaned parts (uploadId: {$uploadId})"); } catch (\Throwable $e) { Logger::error('backup', 'Failed to abort multipart upload: ' . $e->getMessage()); } } private function getEndpoint(string $provider, array $credentials): string { return S3Endpoint::fromCredentials($provider, $credentials)->baseUrl; } private function hashFileSha256(string $filePath): string { $handle = fopen($filePath, 'rb'); if ($handle === false) { throw new \RuntimeException('Cannot open file for hashing: ' . $filePath); } $context = hash_init('sha256'); while (!feof($handle)) { $chunk = fread($handle, 8192); if ($chunk !== false && $chunk !== '') { hash_update($context, $chunk); } } fclose($handle); return hash_final($context); } /** * Delegates to the extracted, unit-tested AWS SigV4 implementation. * * The previous inline version regenerated its own gmdate() while building the * string-to-sign instead of reusing the x-amz-date header it had just set, so a * request that crossed a second boundary was signed for a different timestamp than * it advertised — an intermittent 403 SignatureDoesNotMatch nobody could reproduce. * It also emitted canonical headers in insertion order rather than sorted, which * SigV4 requires. */ private function signRequest(string $method, string $url, array $headers, string $keyId, string $secretKey, string $region, string $payloadHash): string { return (new S3Signer($keyId, $secretKey, $region)) ->authorization($method, $url, $headers, $payloadHash); } /** * Le motif que le stockage a donné, et non le seul code que nous avons lu. * * Voir {@see ResumableCloudUploader::refusal()} : même règle, même raison, et le * décodage vit au cœur pour que les deux chemins ne puissent pas diverger. * * @param array|\WP_Error $response */ private static function refusal($response): string { if (is_wp_error($response)) { return $response->get_error_message(); } return StorageError::describe( (int) wp_remote_retrieve_response_code($response), (string) wp_remote_retrieve_body($response) ); } }