From bff9e11b5000c8bb2685b9ef9cc11be0dc8c5b5c Mon Sep 17 00:00:00 2001 From: Arno DUBOIS Date: Tue, 12 Mar 2024 00:30:32 +0000 Subject: [PATCH] Move to ZipStream integrated size calculation --- app/src/Controllers/ZipController.php | 170 +++++++------------------- composer.json | 1 - phpstan.neon.dist | 1 - 3 files changed, 42 insertions(+), 130 deletions(-) diff --git a/app/src/Controllers/ZipController.php b/app/src/Controllers/ZipController.php index 830c4ae..b65f7eb 100644 --- a/app/src/Controllers/ZipController.php +++ b/app/src/Controllers/ZipController.php @@ -5,15 +5,16 @@ namespace App\Controllers; use App\CallbackStream; use App\Config; use App\Support\Str; +use Exception; use Psr\Http\Message\ResponseInterface; +use RuntimeException; use Slim\Psr7\Request; use Slim\Psr7\Response; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; use Symfony\Contracts\Translation\TranslatorInterface; -use ZipStream\Option\Archive; -use ZipStream\Option\File; -use ZipStream\Option\Method; +use ZipStream\CompressionMethod; +use ZipStream\OperationMode; use ZipStream\ZipStream; class ZipController @@ -25,7 +26,11 @@ class ZipController private TranslatorInterface $translator ) {} - /** Invoke the ZipHandler. */ + /** Invoke the ZipHandler. + * @throws \ZipStream\Exception\FileNotFoundException + * @throws \ZipStream\Exception\FileNotReadableException + * @throws Exception + */ public function __invoke(Request $request, Response $response): ResponseInterface { $path = $request->getQueryParams()['zip']; @@ -44,144 +49,63 @@ class ZipController $files = $this->finder->in($path)->files(); - $response = $this->augmentHeadersWithEstimatedSize($response, $path, $files); + $zip = $this->createZip($path, $files); + $size = $zip->finish(); - return $response->withBody(new CallbackStream(function () use ($path, $files) { - $this->createZip($path, $files); - })); + $response = $this + ->augmentHeadersWithEstimatedSize($response, $size) + ->withBody(new CallbackStream(function () use ($zip) { + $zip->executeSimulation(); + })); + + return $response; } /** Create a zip stream from a directory. * * @throws \ZipStream\Exception\FileNotFoundException * @throws \ZipStream\Exception\FileNotReadableException - * @throws \ZipStream\Exception\OverflowException + * @throws Exception */ - protected function createZip(string $path, Finder $files): void + protected function createZip(string $path, Finder $files): ZipStream { - $compressionMethod = $this->config->get('zip_compress') ? Method::DEFLATE() : Method::STORE(); + $compressionMethod = $this->config->get('zip_compress') ? CompressionMethod::DEFLATE : CompressionMethod::STORE; - $zipStreamOptions = new Archive; - $zipStreamOptions->setLargeFileMethod($compressionMethod); - $zipStreamOptions->setSendHttpHeaders(false); - $zipStreamOptions->setFlushOutput(true); - $zipStreamOptions->setEnableZip64(true); - - $zip = new ZipStream(null, $zipStreamOptions); + $zip = new ZipStream( + sendHttpHeaders: false, + operationMode: OperationMode::SIMULATE_LAX + ); foreach ($files as $file) { - $fileOption = new File; - $fileOption->setMethod($compressionMethod); - $fileOption->setSize($file->getSize()); + $modifiedTime = null; try { - $creationTime = (int) $file->getMTime(); - $fileOption->setTime(new \DateTime('@' . $creationTime)); - } catch (\Exception $e) { - // We couldn't get the creation time, so we don't set it + $modifiedTime = new \DateTime('@' . (int) $file->getMTime()); + } catch (RuntimeException $e) { + $lstat_data = lstat($file->getPathname()); + if ($lstat_data) { + $modifiedTime = new \DateTime('@' . (int) ['mtime']); + } } - $zip->addFileFromPath($this->stripPath($file, $path), (string) $file->getRealPath(), $fileOption); + $zip->addFileFromPath( + $this->stripPath($file, $path), + (string) $file->getRealPath(), + compressionMethod: $compressionMethod, + lastModificationDateTime: $modifiedTime, + exactSize: $file->getSize() + ); } - $zip->finish(); + return $zip; } - protected function augmentHeadersWithEstimatedSize(Response $response, string $path, Finder $files): Response + protected function augmentHeadersWithEstimatedSize(Response $response, int $size): Response { - if (! $this->config->get('zip_compress')) { - $totalSize = 0; - $hasUtf8 = false; - $filesMeta = []; - foreach ($files as $file) { - $fileSize = $file->getSize(); - $totalSize += $fileSize; - $fileName = $this->filterFilename($this->stripPath($file, $path)); - if (! mb_check_encoding(preg_replace('/^\\/+/', '', $fileName), 'ASCII')) { - $hasUtf8 = true; - } - $filesMeta[] = [$fileName, $fileSize]; - } - // If there is more than 4 GB or 2^16 files, it will be a ZIP64, changing the estimation method - if ( - $totalSize >= pow(2, 32) || - count($filesMeta) >= 0xFFFF || - $hasUtf8 - ) { - $estimate = $this->calculateZip64Size($filesMeta); - } else { - $estimate = $this->calculateZipSize($filesMeta); - } - - $response = $response->withHeader('Content-Length', (string) $estimate); - } + $response = $response->withHeader('Content-Length', (string) $size); return $response; } - protected function calculateZipSize(array $filesMeta): int - { - $estimate = 22; - foreach ($filesMeta as $fileMeta) { - $estimate += 76 + 2 * strlen($fileMeta[0]) + $fileMeta[1]; - } - - return $estimate; - } - - protected function calculateZip64Size(array $filesMeta): int - { - $estimate = 0; - foreach ($filesMeta as $fileMeta) { - $beginning = $estimate; - // This is similar from standard Zip - // Adding header size and filename - $header = 30 + strlen($fileMeta[0]); - // With Zip64, the size of the header is variable, so we need to calculate it - $header += $this->calculateZip64ExtraBlockSize($fileMeta, 0); - // Adding file content to the size - $content = $fileMeta[1]; - // Default footer size (data descriptor) including filename - $footer = 46 + strlen($fileMeta[0]); - // This block also gets added at the end of the file, but offsets are differents, so we need to calculate it again - $footer += $this->calculateZip64ExtraBlockSize($fileMeta, $beginning); - $estimate += $header + $content + $footer; - } - // Size of the CDR64 EOF calculated by ZipStream is always 44 + 12 for signature and the size itself - $estimate += 56; - // Size of the CDR64 locator - $estimate += 20; - // Size of the CDR EOF locator - $estimate += 22; - - return $estimate; - } - - protected function calculateZip64ExtraBlockSize(array $fileMeta, int $currentOffset): int - { - // This is where it gets funky - $zip64ExtraBlockSize = 0; - if ($fileMeta[1] >= pow(2, 32)) { - // If file size is more than 2^32, add it to the extra block - $zip64ExtraBlockSize += 16; // 8 for size + 8 for compressed size - } - - // Offset - if ($currentOffset >= pow(2, 32)) { - $zip64ExtraBlockSize += 8; // if offset is more than 2^32, then we add it to the extra block - } - - if ($zip64ExtraBlockSize != 0) { - $zip64ExtraBlockSize += 4; // 2 for header ID + 2 for the block size - } - - // If the filename or path does not fit into ASCII, then ZipStream will add the two remaining fields with special values - if (! mb_check_encoding(preg_replace('/^\\/+/', '', $fileMeta[0]), 'ASCII')) { - $zip64ExtraBlockSize += 4; - } - - return $zip64ExtraBlockSize; - } - /** Return the path to a file with the preceding root path stripped. */ protected function stripPath(SplFileInfo $file, string $path): string { @@ -201,14 +125,4 @@ class ZipController return $filename == '.' ? 'Home' : $filename; } - - /** Filter a file name to remove invalid characters inside a Zip */ - protected function filterFilename(string $filename): string - { - // strip leading slashes from file name - // (fixes bug in windows archive viewer) - $filename = (string) preg_replace('/^\\/+/', '', $filename); - - return str_replace(['\\', ':', '*', '?', '"', '<', '>', '|'], '_', $filename); - } } diff --git a/composer.json b/composer.json index e4955a3..e2113ff 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,6 @@ "phpstan/phpstan": "^1.0", "psy/psysh": "^0.12.0", "symfony/var-dumper": "^6.0", - "timeweb/phpstan-enum": "^3.1", "yoast/phpunit-polyfills": "^2.0" }, "suggest": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 44ba6fc..db4be25 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -39,4 +39,3 @@ parameters: includes: - phpstan-baseline.neon - phpstan-ignores.neon - - app/vendor/timeweb/phpstan-enum/extension.neon