From 08d3ae672df858ff6cc5068931fcc7be372a4d18 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Thu, 10 Sep 2026 15:53:46 +0530 Subject: [PATCH] fix(s3): send explicit Content-Length on every request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading large (chunked) files to S3-compatible storage such as GCS failed with HTTP 411 "POST requests require a Content-length header". call() handed a streamed body to the transport without a Content-Length header. The cURL adapter then streams a body of unknown size with Transfer-Encoding: chunked, and emits no length at all for an empty-body POST (createMultipartUpload) — both of which S3-compatible services reject with 411. hashBody() already reads the whole (seekable) body once to sign it, so count the bytes there and set an explicit, signed content-length header for every request (0 for empty bodies). The value equals the full body size, matching what the transport sends for a size-known stream. Fixes appwrite/appwrite#13548 --- src/Storage/Device/S3.php | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Storage/Device/S3.php b/src/Storage/Device/S3.php index a47db793..e4ea9cc5 100644 --- a/src/Storage/Device/S3.php +++ b/src/Storage/Device/S3.php @@ -735,11 +735,12 @@ protected function call(string $method, string $uri, StreamInterface|string $dat $url = $this->fqdn . $uri . '?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); if ($data instanceof StreamInterface) { - [$md5, $sha256] = $this->hashBody($data); + [$md5, $sha256, $length] = $this->hashBody($data); $body = $data; } else { $md5 = base64_encode(md5($data, true)); $sha256 = hash('sha256', $data); + $length = \strlen($data); $body = new Stream($data); } @@ -747,6 +748,12 @@ protected function call(string $method, string $uri, StreamInterface|string $dat $headers['host'] = $this->host; $headers['date'] = gmdate('D, d M Y H:i:s T'); $headers['content-md5'] = $md5; + // Send an explicit Content-Length (signed, alongside content-md5). Without + // it the cURL transport streams the body with Transfer-Encoding: chunked — + // or omits the header on an empty POST — which S3-compatible services such + // as GCS reject with HTTP 411. The value is the full body size, so it also + // matches what the transport sends for a size-known stream. + $headers['content-length'] = (string) $length; $amzHeaders = array_filter($amzHeaders, fn(string $value): bool => $value !== ''); $amzHeaders['x-amz-date'] = gmdate('Ymd\THis\Z'); @@ -810,7 +817,7 @@ protected function call(string $method, string $uri, StreamInterface|string $dat * the cURL adapter rewinds seekable bodies before sending, so the * signature must cover the full stream. * - * @return array{string, string} Base64 MD5 and hex SHA-256 of the full stream + * @return array{string, string, int} Base64 MD5, hex SHA-256, and byte length of the full stream */ private function hashBody(StreamInterface $body): array { @@ -821,17 +828,19 @@ private function hashBody(StreamInterface $body): array $body->rewind(); $md5 = hash_init('md5'); $sha256 = hash_init('sha256'); + $length = 0; while (! $body->eof()) { $chunk = $body->read(self::PIPE_CHUNK_SIZE); if ($chunk === '') { break; } + $length += \strlen($chunk); hash_update($md5, $chunk); hash_update($sha256, $chunk); } $body->rewind(); - return [base64_encode(hash_final($md5, true)), hash_final($sha256)]; + return [base64_encode(hash_final($md5, true)), hash_final($sha256), $length]; } /**