From 88a1e6f8c2f12dabcc9cb9baa267212445172d7a Mon Sep 17 00:00:00 2001 From: Arno DUBOIS Date: Tue, 23 May 2023 00:47:26 +0000 Subject: [PATCH 1/6] Implement Zip streaming --- app/config/app.php | 7 + app/src/CallbackStream.php | 238 ++++++++++++++++++++++++++ app/src/Controllers/ZipController.php | 62 +++++-- composer.json | 1 + composer.lock | 83 ++++++++- 5 files changed, 371 insertions(+), 20 deletions(-) create mode 100644 app/src/CallbackStream.php diff --git a/app/config/app.php b/app/config/app.php index 23485a9..e4a1e9b 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -67,6 +67,13 @@ return [ */ 'zip_downloads' => env('ZIP_DOWNLOADS', true), + /** + * Compress Zip using Deflate + * + * Default value: false + */ + 'zip_compress' => env('ZIP_COMPRESS', false), + /** * Your Google analytics tracking ID. * diff --git a/app/src/CallbackStream.php b/app/src/CallbackStream.php new file mode 100644 index 0000000..3b69d50 --- /dev/null +++ b/app/src/CallbackStream.php @@ -0,0 +1,238 @@ +callback = $callback; + } + + /** + * @return string + */ + public function __toString() + { + return ''; + } + + /** + * Closes the stream and any underlying resources. + * + * @return void + */ + public function close() + { + } + + /** + * Separates any underlying resources from the stream. + * + * After the stream has been detached, the stream is in an unusable state. + * + * @return resource|null Underlying PHP stream, if any + */ + public function detach() + { + $this->callback = null; + + return null; + } + + /** + * Get the size of the stream if known. + * + * @return int|null Returns the size in bytes if known, or null if unknown. + */ + public function getSize() + { + return null; + } + + /** + * Returns the current position of the file read/write pointer + * + * @throws \RuntimeException on error. + * @return int Position of the file pointer + */ + public function tell() + { + return 0; + } + + /** + * Returns true if the stream is at the end of the stream. + * + * @return bool + */ + public function eof() + { + return $this->called; + } + + /** + * Returns whether the stream is seekable. + * + * @return bool + */ + public function isSeekable() + { + return false; + } + + /** + * Seek to a position in the stream. + * + * @link http://www.php.net/manual/en/function.fseek.php + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. + * @throws \RuntimeException on failure. + * + * @return void + */ + public function seek($offset, $whence = SEEK_SET) + { + } + + /** + * Seek to the beginning of the stream. + * + * If the stream is not seekable, this method will raise an exception; + * otherwise, it will perform a seek(0). + * + * @throws \RuntimeException on failure. + * @link http://www.php.net/manual/en/function.fseek.php + * @see seek() + * + * @return bool + */ + public function rewind() + { + return false; + } + + /** + * Returns whether the stream is writable. + * + * @return bool + */ + public function isWritable() + { + return false; + } + + /** + * Write data to the stream. + * + * @param string $string The string that is to be written. + * @throws \RuntimeException on failure. + * @return int Returns the number of bytes written to the stream. + */ + public function write($string) + { + return 0; + } + + /** + * Returns whether the stream is readable. + * + * @return bool + */ + public function isReadable() + { + return true; + } + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return them. + * Fewer than $length bytes may be returned if underlying stream call returns fewer bytes. + * + * @throws \RuntimeException if an error occurs. + * + * @return string Returns the data read from the stream, or an empty string if no bytes are available. + */ + public function read($length) + { + if ($this->called || !$this->callback) { + return ''; + } + + $this->called = true; + + // Execute the callback + call_user_func($this->callback); + + return ''; + } + + /** + * Returns the remaining contents in a string + * + * @throws \RuntimeException if unable to read or an error occurs while reading. + * + * @return string + */ + public function getContents() + { + return ''; + } + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * + * The keys returned are identical to the keys returned from PHP's + * stream_get_meta_data() function. + * + * @link http://php.net/manual/en/function.stream-get-meta-data.php + * @param string $key Specific metadata to retrieve. + * @return array|mixed|null Returns an associative array if no key is + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. + */ + public function getMetadata($key = null) + { + if ($key === null) { + return []; + } + + return null; + } +} \ No newline at end of file diff --git a/app/src/Controllers/ZipController.php b/app/src/Controllers/ZipController.php index d254ae4..e1965a4 100644 --- a/app/src/Controllers/ZipController.php +++ b/app/src/Controllers/ZipController.php @@ -2,6 +2,7 @@ namespace App\Controllers; +use App\CallbackStream; use App\Config; use App\Support\Str; use App\TemporaryFile; @@ -12,7 +13,10 @@ use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Translation\TranslatorInterface; -use ZipArchive; +use ZipStream\Option\Archive; +use ZipStream\Option\File; +use ZipStream\Option\Method; +use ZipStream\ZipStream; class ZipController { @@ -33,34 +37,54 @@ class ZipController return $response->withStatus(404, $this->translator->trans('error.file_not_found')); } - $response->getBody()->write( - $this->cache->get(sprintf('zip-%s', sha1($path)), function () use ($path): string { - return $this->createZip($path)->getContents(); - }) - ); - - return $response->withHeader('Content-Type', 'application/zip') + $response = $response + ->withHeader('Content-Type', 'application/zip') ->withHeader('Content-Disposition', sprintf( 'attachment; filename="%s.zip"', $this->generateFileName($path) - )); + )) + ->withHeader('X-Accel-Buffering', 'no'); + + $files = $this->finder->in($path)->files(); + + $response = $this->augmentHeadersWithEstimatedSize($response, $path, $files); + + return $response->withBody(new CallbackStream(function () use ($path, $files) { + $this->createZip($path, $files); + })); } - /** Create a zip file from a directory. */ - protected function createZip(string $path): TemporaryFile + /** Create a zip stream from a directory. */ + protected function createZip(string $path, Finder $files): void { - $zip = new ZipArchive; - $zip->open((string) $tempFile = new TemporaryFile( - $this->config->get('cache_path') - ), ZipArchive::CREATE | ZipArchive::OVERWRITE); + $compressionMethod = $this->config->get('zip_compress') ? Method::DEFLATE() : Method::STORE(); - foreach ($this->finder->in($path)->files() as $file) { - $zip->addFile((string) $file->getRealPath(), $this->stripPath($file, $path)); + $zipStreamOptions = new Archive(); + $zipStreamOptions->setLargeFileMethod($compressionMethod); + $zipStreamOptions->setFlushOutput(true); + + $zip = new ZipStream(null, $zipStreamOptions); + + $fileOption = new File(); + $fileOption->setMethod($compressionMethod); + + foreach ($files as $file) { + $zip->addFileFromPath($this->stripPath($file, $path), (string) $file->getRealPath(), $fileOption); } - $zip->close(); + $zip->finish(); + } - return $tempFile; + protected function augmentHeadersWithEstimatedSize(Response $response, string $path, Finder $files): Response + { + $estimate = 22; + if (!$this->config->get('zip_compress')) { + foreach ($files as $file) { + $estimate += 76 + 2 * strlen($this->stripPath($file, $path)) + $file->getSize(); + } + $response = $response->withHeader('Content-Length', (string) $estimate); + } + return $response; } /** Return the path to a file with the preceding root path stripped. */ diff --git a/composer.json b/composer.json index 70bc4e8..e2113ff 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "erusev/parsedown-extra": "^0.8.1", "filp/whoops": "^2.7", "phlak/splat": "^5.0", + "maennchen/zipstream-php": "^3.1", "php-di/php-di": "^7.0", "php-di/slim-bridge": "^3.0", "psr/http-message": "^1.1", diff --git a/composer.lock b/composer.lock index cbea0d5..16aab5b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d3cbff5a9e5ef4a690cb19eca7e58cf8", + "content-hash": "c780d21e8e8d1639c1b4fdf63b62d5ed", "packages": [ { "name": "erusev/parsedown", @@ -356,6 +356,87 @@ }, "time": "2023-11-08T14:08:06+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "b8174494eda667f7d13876b4a7bfef0f62a7c0d1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/b8174494eda667f7d13876b4a7bfef0f62a7c0d1", + "reference": "b8174494eda667f7d13876b4a7bfef0f62a7c0d1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.1" + }, + "require-dev": { + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^5.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.0" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + }, + { + "url": "https://opencollective.com/zipstream", + "type": "open_collective" + } + ], + "time": "2023-06-21T14:59:35+00:00" + }, { "name": "nikic/fast-route", "version": "v1.3.0", From 41d77394ad0ed4c4594502a7287e202da094b2ed Mon Sep 17 00:00:00 2001 From: Arno DUBOIS Date: Fri, 26 May 2023 19:33:21 +0000 Subject: [PATCH 2/6] Fix tests for Zip streaming --- app/config/app.php | 3 +- app/src/CallbackStream.php | 81 ++-- app/src/TemporaryFile.php | 33 -- composer.json | 1 + composer.lock | 531 ++++++++++++++---------- phpstan.neon.dist | 1 + tests/Controllers/ZipControllerTest.php | 7 +- tests/TemporaryFileTest.php | 38 -- 8 files changed, 355 insertions(+), 340 deletions(-) delete mode 100644 app/src/TemporaryFile.php delete mode 100644 tests/TemporaryFileTest.php diff --git a/app/config/app.php b/app/config/app.php index e4a1e9b..fe05bf9 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -68,7 +68,8 @@ return [ 'zip_downloads' => env('ZIP_DOWNLOADS', true), /** - * Compress Zip using Deflate + * Compress Zip using Deflate. The main drawback of enabling this option is that + * the file size cannot be estimated, and it can also prevent the resuming of the download. * * Default value: false */ diff --git a/app/src/CallbackStream.php b/app/src/CallbackStream.php index 3b69d50..6afa8ef 100644 --- a/app/src/CallbackStream.php +++ b/app/src/CallbackStream.php @@ -3,7 +3,8 @@ /** * @copyright Copyright (c) 2015 Matthew Weier O'Phinney (https://mwop.net) * @license http://opensource.org/licenses/BSD-2-Clause BSD-2-Clause - * @link https://github.com/phly/psr7examples/blob/master/src/CallbackStream.php + * + * @see https://github.com/phly/psr7examples/blob/master/src/CallbackStream.php */ namespace App; @@ -20,39 +21,25 @@ use Psr\Http\Message\StreamInterface; */ class CallbackStream implements StreamInterface { - /** - * @var callable|null - */ + /** @var callable|null */ private $callback; - /** - * Whether the callback has been previously invoked. - * - * @var bool - */ + /** Whether the callback has been previously invoked. */ private bool $called = false; - /** - * @param callable $callback The callback function that echos the body content - */ + /** @param callable $callback The callback function that echos the body content */ public function __construct(callable $callback) { $this->callback = $callback; } - /** - * @return string - */ + /** @return string */ public function __toString() { return ''; } - /** - * Closes the stream and any underlying resources. - * - * @return void - */ + /** Closes the stream and any underlying resources. */ public function close() { } @@ -74,7 +61,7 @@ class CallbackStream implements StreamInterface /** * Get the size of the stream if known. * - * @return int|null Returns the size in bytes if known, or null if unknown. + * @return int|null returns the size in bytes if known, or null if unknown */ public function getSize() { @@ -82,9 +69,8 @@ class CallbackStream implements StreamInterface } /** - * Returns the current position of the file read/write pointer + * Returns the current position of the file read/write pointer. * - * @throws \RuntimeException on error. * @return int Position of the file pointer */ public function tell() @@ -115,18 +101,16 @@ class CallbackStream implements StreamInterface /** * Seek to a position in the stream. * - * @link http://www.php.net/manual/en/function.fseek.php + * @see http://www.php.net/manual/en/function.fseek.php + * * @param int $offset Stream offset * @param int $whence Specifies how the cursor position will be calculated - * based on the seek offset. Valid values are identical to the built-in - * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to - * offset bytes SEEK_CUR: Set position to current location plus offset - * SEEK_END: Set position to end-of-stream plus offset. - * @throws \RuntimeException on failure. - * - * @return void + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. */ - public function seek($offset, $whence = SEEK_SET) + public function seek($offset, $whence = SEEK_SET): void { } @@ -136,8 +120,7 @@ class CallbackStream implements StreamInterface * If the stream is not seekable, this method will raise an exception; * otherwise, it will perform a seek(0). * - * @throws \RuntimeException on failure. - * @link http://www.php.net/manual/en/function.fseek.php + * @see http://www.php.net/manual/en/function.fseek.php * @see seek() * * @return bool @@ -160,9 +143,9 @@ class CallbackStream implements StreamInterface /** * Write data to the stream. * - * @param string $string The string that is to be written. - * @throws \RuntimeException on failure. - * @return int Returns the number of bytes written to the stream. + * @param string $string the string that is to be written + * + * @return int returns the number of bytes written to the stream */ public function write($string) { @@ -183,15 +166,13 @@ class CallbackStream implements StreamInterface * Read data from the stream. * * @param int $length Read up to $length bytes from the object and return them. - * Fewer than $length bytes may be returned if underlying stream call returns fewer bytes. + * Fewer than $length bytes may be returned if underlying stream call returns fewer bytes. * - * @throws \RuntimeException if an error occurs. - * - * @return string Returns the data read from the stream, or an empty string if no bytes are available. + * @return string returns the data read from the stream, or an empty string if no bytes are available */ public function read($length) { - if ($this->called || !$this->callback) { + if ($this->called || ! $this->callback) { return ''; } @@ -204,9 +185,7 @@ class CallbackStream implements StreamInterface } /** - * Returns the remaining contents in a string - * - * @throws \RuntimeException if unable to read or an error occurs while reading. + * Returns the remaining contents in a string. * * @return string */ @@ -221,11 +200,13 @@ class CallbackStream implements StreamInterface * The keys returned are identical to the keys returned from PHP's * stream_get_meta_data() function. * - * @link http://php.net/manual/en/function.stream-get-meta-data.php - * @param string $key Specific metadata to retrieve. + * @see http://php.net/manual/en/function.stream-get-meta-data.php + * + * @param string $key specific metadata to retrieve + * * @return array|mixed|null Returns an associative array if no key is - * provided. Returns a specific key value if a key is provided and the - * value is found, or null if the key is not found. + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. */ public function getMetadata($key = null) { @@ -235,4 +216,4 @@ class CallbackStream implements StreamInterface return null; } -} \ No newline at end of file +} diff --git a/app/src/TemporaryFile.php b/app/src/TemporaryFile.php deleted file mode 100644 index dfb85e7..0000000 --- a/app/src/TemporaryFile.php +++ /dev/null @@ -1,33 +0,0 @@ -path = (string) tempnam($dir, $prefix); - } - - /** Destroy this TemporaryFile object. */ - public function __destruct() - { - unlink($this->path); - } - - /** Get the path to the temporary file. */ - public function __toString(): string - { - return $this->path; - } - - /** Get the raw contents of the file. */ - public function getContents(): string - { - return (string) file_get_contents($this->path); - } -} diff --git a/composer.json b/composer.json index e2113ff..e4955a3 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "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/composer.lock b/composer.lock index 16aab5b..927d7f4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c780d21e8e8d1639c1b4fdf63b62d5ed", + "content-hash": "a5d9a485ecfd13cf57171cb78efb3dc4", "packages": [ { "name": "erusev/parsedown", @@ -1291,16 +1291,16 @@ }, { "name": "slim/slim", - "version": "4.12.0", + "version": "4.13.0", "source": { "type": "git", "url": "https://github.com/slimphp/Slim.git", - "reference": "e9e99c2b24398b967841c6c4c3048622cc7e2b18" + "reference": "038fd5713d5a41636fdff0e8dcceedecdd17fc17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slimphp/Slim/zipball/e9e99c2b24398b967841c6c4c3048622cc7e2b18", - "reference": "e9e99c2b24398b967841c6c4c3048622cc7e2b18", + "url": "https://api.github.com/repos/slimphp/Slim/zipball/038fd5713d5a41636fdff0e8dcceedecdd17fc17", + "reference": "038fd5713d5a41636fdff0e8dcceedecdd17fc17", "shasum": "" }, "require": { @@ -1309,7 +1309,7 @@ "php": "^7.4 || ^8.0", "psr/container": "^1.0 || ^2.0", "psr/http-factory": "^1.0", - "psr/http-message": "^1.1", + "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1317,19 +1317,19 @@ "require-dev": { "adriansuter/php-autoload-override": "^1.4", "ext-simplexml": "*", - "guzzlehttp/psr7": "^2.5", + "guzzlehttp/psr7": "^2.6", "httpsoft/http-message": "^1.1", "httpsoft/http-server-request": "^1.1", - "laminas/laminas-diactoros": "^2.17", + "laminas/laminas-diactoros": "^2.17 || ^3", "nyholm/psr7": "^1.8", - "nyholm/psr7-server": "^1.0", - "phpspec/prophecy": "^1.17", - "phpspec/prophecy-phpunit": "^2.0", + "nyholm/psr7-server": "^1.1", + "phpspec/prophecy": "^1.19", + "phpspec/prophecy-phpunit": "^2.1", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.6", "slim/http": "^1.3", "slim/psr7": "^1.6", - "squizlabs/php_codesniffer": "^3.7" + "squizlabs/php_codesniffer": "^3.9" }, "suggest": { "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware", @@ -1402,7 +1402,7 @@ "type": "tidelift" } ], - "time": "2023-07-23T04:54:29+00:00" + "time": "2024-03-03T21:25:30+00:00" }, { "name": "slim/twig-view", @@ -1471,16 +1471,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.2", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "14a75869bbb41cb35bc5d9d322473928c6f3f978" + "reference": "0ef36534694c572ff526d91c7181f3edede176e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/14a75869bbb41cb35bc5d9d322473928c6f3f978", - "reference": "14a75869bbb41cb35bc5d9d322473928c6f3f978", + "url": "https://api.github.com/repos/symfony/cache/zipball/0ef36534694c572ff526d91c7181f3edede176e7", + "reference": "0ef36534694c572ff526d91c7181f3edede176e7", "shasum": "" }, "require": { @@ -1547,7 +1547,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.2" + "source": "https://github.com/symfony/cache/tree/v6.4.4" }, "funding": [ { @@ -1563,7 +1563,7 @@ "type": "tidelift" } ], - "time": "2023-12-29T15:34:34+00:00" + "time": "2024-02-22T20:27:10+00:00" }, { "name": "symfony/cache-contracts", @@ -1774,16 +1774,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ef4d7e442ca910c4764bce785146269b30cb5fc4", + "reference": "ef4d7e442ca910c4764bce785146269b30cb5fc4", "shasum": "" }, "require": { @@ -1797,9 +1797,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -1836,7 +1833,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.29.0" }, "funding": [ { @@ -1852,20 +1849,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "42292d99c55abe617799667f454222c54c60e229" + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", - "reference": "42292d99c55abe617799667f454222c54c60e229", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9773676c8a1bb1f8d4340a62efe641cf76eda7ec", + "reference": "9773676c8a1bb1f8d4340a62efe641cf76eda7ec", "shasum": "" }, "require": { @@ -1879,9 +1876,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -1919,7 +1913,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.29.0" }, "funding": [ { @@ -1935,20 +1929,20 @@ "type": "tidelift" } ], - "time": "2023-07-28T09:04:16+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", + "reference": "87b68208d5c1188808dd7839ee1e6c8ec3b02f1b", "shasum": "" }, "require": { @@ -1956,9 +1950,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -2002,7 +1993,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.29.0" }, "funding": [ { @@ -2018,20 +2009,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b" + "reference": "c565ad1e63f30e7477fc40738343c62b40bc672d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/7581cd600fa9fd681b797d00b02f068e2f13263b", - "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/c565ad1e63f30e7477fc40738343c62b40bc672d", + "reference": "c565ad1e63f30e7477fc40738343c62b40bc672d", "shasum": "" }, "require": { @@ -2039,9 +2030,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -2081,7 +2069,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.29.0" }, "funding": [ { @@ -2097,7 +2085,7 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/service-contracts", @@ -2183,16 +2171,16 @@ }, { "name": "symfony/translation", - "version": "v6.4.2", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "a2ab2ec1a462e53016de8e8d5e8912bfd62ea681" + "reference": "bce6a5a78e94566641b2594d17e48b0da3184a8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/a2ab2ec1a462e53016de8e8d5e8912bfd62ea681", - "reference": "a2ab2ec1a462e53016de8e8d5e8912bfd62ea681", + "url": "https://api.github.com/repos/symfony/translation/zipball/bce6a5a78e94566641b2594d17e48b0da3184a8e", + "reference": "bce6a5a78e94566641b2594d17e48b0da3184a8e", "shasum": "" }, "require": { @@ -2215,7 +2203,7 @@ "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.13", + "nikic/php-parser": "^4.18|^5.0", "psr/log": "^1|^2|^3", "symfony/config": "^5.4|^6.0|^7.0", "symfony/console": "^5.4|^6.0|^7.0", @@ -2258,7 +2246,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.2" + "source": "https://github.com/symfony/translation/tree/v6.4.4" }, "funding": [ { @@ -2274,7 +2262,7 @@ "type": "tidelift" } ], - "time": "2023-12-18T09:25:29+00:00" + "time": "2024-02-20T13:16:58+00:00" }, { "name": "symfony/translation-contracts", @@ -2356,16 +2344,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "c40f7d17e91d8b407582ed51a2bbf83c52c367f6" + "reference": "b439823f04c98b84d4366c79507e9da6230944b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c40f7d17e91d8b407582ed51a2bbf83c52c367f6", - "reference": "c40f7d17e91d8b407582ed51a2bbf83c52c367f6", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b439823f04c98b84d4366c79507e9da6230944b1", + "reference": "b439823f04c98b84d4366c79507e9da6230944b1", "shasum": "" }, "require": { @@ -2421,7 +2409,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.0" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.4" }, "funding": [ { @@ -2437,20 +2425,20 @@ "type": "tidelift" } ], - "time": "2023-11-09T08:28:32+00:00" + "time": "2024-02-15T11:23:52+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.2", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "5fe9a0021b8d35e67d914716ec8de50716a68e7e" + "reference": "0bd342e24aef49fc82a21bd4eedd3e665d177e5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/5fe9a0021b8d35e67d914716ec8de50716a68e7e", - "reference": "5fe9a0021b8d35e67d914716ec8de50716a68e7e", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/0bd342e24aef49fc82a21bd4eedd3e665d177e5b", + "reference": "0bd342e24aef49fc82a21bd4eedd3e665d177e5b", "shasum": "" }, "require": { @@ -2496,7 +2484,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.2" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.4" }, "funding": [ { @@ -2512,20 +2500,20 @@ "type": "tidelift" } ], - "time": "2023-12-27T08:18:35+00:00" + "time": "2024-02-26T08:37:45+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "4f9237a1bb42455d609e6687d2613dde5b41a587" + "reference": "d75715985f0f94f978e3a8fa42533e10db921b90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/4f9237a1bb42455d609e6687d2613dde5b41a587", - "reference": "4f9237a1bb42455d609e6687d2613dde5b41a587", + "url": "https://api.github.com/repos/symfony/yaml/zipball/d75715985f0f94f978e3a8fa42533e10db921b90", + "reference": "d75715985f0f94f978e3a8fa42533e10db921b90", "shasum": "" }, "require": { @@ -2568,7 +2556,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.0" + "source": "https://github.com/symfony/yaml/tree/v6.4.3" }, "funding": [ { @@ -2584,7 +2572,7 @@ "type": "tidelift" } ], - "time": "2023-11-06T11:00:25+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "tightenco/collect", @@ -2881,16 +2869,16 @@ }, { "name": "composer/pcre", - "version": "3.1.1", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "00104306927c7a0919b4ced2aaa6782c1e61a3c9" + "reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/00104306927c7a0919b4ced2aaa6782c1e61a3c9", - "reference": "00104306927c7a0919b4ced2aaa6782c1e61a3c9", + "url": "https://api.github.com/repos/composer/pcre/zipball/4775f35b2d70865807c89d32c8e7385b86eb0ace", + "reference": "4775f35b2d70865807c89d32c8e7385b86eb0ace", "shasum": "" }, "require": { @@ -2932,7 +2920,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.1.1" + "source": "https://github.com/composer/pcre/tree/3.1.2" }, "funding": [ { @@ -2948,7 +2936,7 @@ "type": "tidelift" } ], - "time": "2023-10-11T07:11:09+00:00" + "time": "2024-03-07T15:38:35+00:00" }, { "name": "composer/semver", @@ -3169,25 +3157,26 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.44.0", + "version": "v3.51.0", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "5445834057a744c1a434ed60fcac566b4de3a0f2" + "reference": "127fa74f010da99053e3f5b62672615b72dd6efd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/5445834057a744c1a434ed60fcac566b4de3a0f2", - "reference": "5445834057a744c1a434ed60fcac566b4de3a0f2", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/127fa74f010da99053e3f5b62672615b72dd6efd", + "reference": "127fa74f010da99053e3f5b62672615b72dd6efd", "shasum": "" }, "require": { "composer/semver": "^3.4", "composer/xdebug-handler": "^3.0.3", + "ext-filter": "*", "ext-json": "*", "ext-tokenizer": "*", "php": "^7.4 || ^8.0", - "sebastian/diff": "^4.0 || ^5.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0", "symfony/console": "^5.4 || ^6.0 || ^7.0", "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", @@ -3208,7 +3197,8 @@ "php-cs-fixer/accessible-object": "^1.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.4", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.4", - "phpunit/phpunit": "^9.6 || ^10.5.5", + "phpunit/phpunit": "^9.6 || ^10.5.5 || ^11.0.2", + "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0", "symfony/yaml": "^5.4 || ^6.0 || ^7.0" }, "suggest": { @@ -3247,7 +3237,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.44.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.51.0" }, "funding": [ { @@ -3255,7 +3245,7 @@ "type": "github" } ], - "time": "2023-12-29T20:21:16+00:00" + "time": "2024-02-28T19:50:06+00:00" }, { "name": "johnkary/phpunit-speedtrap", @@ -3430,26 +3420,91 @@ "time": "2023-03-08T13:26:56+00:00" }, { - "name": "nikic/php-parser", - "version": "v4.18.0", + "name": "myclabs/php-enum", + "version": "1.8.4", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999" + "url": "https://github.com/myclabs/php-enum.git", + "reference": "a867478eae49c9f59ece437ae7f9506bfaa27483" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/1bcbb2179f97633e98bbbc87044ee2611c7d7999", - "reference": "1bcbb2179f97633e98bbbc87044ee2611c7d7999", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/a867478eae49c9f59ece437ae7f9506bfaa27483", + "reference": "a867478eae49c9f59ece437ae7f9506bfaa27483", "shasum": "" }, "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "http://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.4" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2022-08-04T09:53:51+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.0.2", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/139676794dc1e9231bf7bcd123cfc0c99182cb13", + "reference": "139676794dc1e9231bf7bcd123cfc0c99182cb13", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" }, "bin": [ "bin/php-parse" @@ -3457,7 +3512,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -3481,26 +3536,27 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.18.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.0.2" }, - "time": "2023-12-10T21:03:43+00:00" + "time": "2024-03-05T20:51:40+00:00" }, { "name": "phar-io/manifest", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { "ext-dom": "*", + "ext-libxml": "*", "ext-phar": "*", "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", @@ -3541,9 +3597,15 @@ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.3" + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2021-07-20T11:28:43+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { "name": "phar-io/version", @@ -3658,16 +3720,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.50", + "version": "1.10.60", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4" + "reference": "95dcea7d6c628a3f2f56d091d8a0219485a86bbe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/06a98513ac72c03e8366b5a0cb00750b487032e4", - "reference": "06a98513ac72c03e8366b5a0cb00750b487032e4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/95dcea7d6c628a3f2f56d091d8a0219485a86bbe", + "reference": "95dcea7d6c628a3f2f56d091d8a0219485a86bbe", "shasum": "" }, "require": { @@ -3716,20 +3778,20 @@ "type": "tidelift" } ], - "time": "2023-12-13T10:59:42+00:00" + "time": "2024-03-07T13:30:19+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.30", + "version": "9.2.31", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "ca2bd87d2f9215904682a9cb9bb37dda98e76089" + "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/ca2bd87d2f9215904682a9cb9bb37dda98e76089", - "reference": "ca2bd87d2f9215904682a9cb9bb37dda98e76089", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/48c34b5d8d983006bd2adc2d0de92963b9155965", + "reference": "48c34b5d8d983006bd2adc2d0de92963b9155965", "shasum": "" }, "require": { @@ -3786,7 +3848,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.30" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.31" }, "funding": [ { @@ -3794,7 +3856,7 @@ "type": "github" } ], - "time": "2023-12-22T06:47:57+00:00" + "time": "2024-03-02T06:37:42+00:00" }, { "name": "phpunit/php-file-iterator", @@ -4039,16 +4101,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.15", + "version": "9.6.17", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1" + "reference": "1a156980d78a6666721b7e8e8502fe210b587fcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/05017b80304e0eb3f31d90194a563fd53a6021f1", - "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/1a156980d78a6666721b7e8e8502fe210b587fcd", + "reference": "1a156980d78a6666721b7e8e8502fe210b587fcd", "shasum": "" }, "require": { @@ -4122,7 +4184,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.15" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.17" }, "funding": [ { @@ -4138,7 +4200,7 @@ "type": "tidelift" } ], - "time": "2023-12-01T16:55:19+00:00" + "time": "2024-02-23T13:14:51+00:00" }, { "name": "psr/event-dispatcher", @@ -4271,16 +4333,16 @@ }, { "name": "sebastian/cli-parser", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", "shasum": "" }, "require": { @@ -4315,7 +4377,7 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" }, "funding": [ { @@ -4323,7 +4385,7 @@ "type": "github" } ], - "time": "2020-09-28T06:08:49+00:00" + "time": "2024-03-02T06:27:43+00:00" }, { "name": "sebastian/code-unit", @@ -4569,16 +4631,16 @@ }, { "name": "sebastian/diff", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131" + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131", - "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", "shasum": "" }, "require": { @@ -4623,7 +4685,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" }, "funding": [ { @@ -4631,7 +4693,7 @@ "type": "github" } ], - "time": "2023-05-07T05:35:17+00:00" + "time": "2024-03-02T06:30:58+00:00" }, { "name": "sebastian/environment", @@ -4698,16 +4760,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", + "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", "shasum": "" }, "require": { @@ -4763,7 +4825,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" }, "funding": [ { @@ -4771,20 +4833,20 @@ "type": "github" } ], - "time": "2022-09-14T06:03:37+00:00" + "time": "2024-03-02T06:33:00+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.6", + "version": "5.0.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bde739e7565280bda77be70044ac1047bc007e34" + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", - "reference": "bde739e7565280bda77be70044ac1047bc007e34", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", "shasum": "" }, "require": { @@ -4827,7 +4889,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.6" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" }, "funding": [ { @@ -4835,7 +4897,7 @@ "type": "github" } ], - "time": "2023-08-02T09:26:13+00:00" + "time": "2024-03-02T06:35:11+00:00" }, { "name": "sebastian/lines-of-code", @@ -5235,16 +5297,16 @@ }, { "name": "symfony/console", - "version": "v6.4.1", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "a550a7c99daeedef3f9d23fb82e3531525ff11fd" + "reference": "0d9e4eb5ad413075624378f474c4167ea202de78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/a550a7c99daeedef3f9d23fb82e3531525ff11fd", - "reference": "a550a7c99daeedef3f9d23fb82e3531525ff11fd", + "url": "https://api.github.com/repos/symfony/console/zipball/0d9e4eb5ad413075624378f474c4167ea202de78", + "reference": "0d9e4eb5ad413075624378f474c4167ea202de78", "shasum": "" }, "require": { @@ -5309,7 +5371,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.1" + "source": "https://github.com/symfony/console/tree/v6.4.4" }, "funding": [ { @@ -5325,20 +5387,20 @@ "type": "tidelift" } ], - "time": "2023-11-30T10:54:28+00:00" + "time": "2024-02-22T20:27:10+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "d76d2632cfc2206eecb5ad2b26cd5934082941b6" + "reference": "ae9d3a6f3003a6caf56acd7466d8d52378d44fef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d76d2632cfc2206eecb5ad2b26cd5934082941b6", - "reference": "d76d2632cfc2206eecb5ad2b26cd5934082941b6", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ae9d3a6f3003a6caf56acd7466d8d52378d44fef", + "reference": "ae9d3a6f3003a6caf56acd7466d8d52378d44fef", "shasum": "" }, "require": { @@ -5389,7 +5451,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.3" }, "funding": [ { @@ -5405,7 +5467,7 @@ "type": "tidelift" } ], - "time": "2023-07-27T06:52:43+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5485,16 +5547,16 @@ }, { "name": "symfony/filesystem", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "952a8cb588c3bc6ce76f6023000fb932f16a6e59" + "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/952a8cb588c3bc6ce76f6023000fb932f16a6e59", - "reference": "952a8cb588c3bc6ce76f6023000fb932f16a6e59", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", + "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", "shasum": "" }, "require": { @@ -5528,7 +5590,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.0" + "source": "https://github.com/symfony/filesystem/tree/v6.4.3" }, "funding": [ { @@ -5544,7 +5606,7 @@ "type": "tidelift" } ], - "time": "2023-07-26T17:27:13+00:00" + "time": "2024-01-23T14:51:35+00:00" }, { "name": "symfony/options-resolver", @@ -5615,16 +5677,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "875e90aeea2777b6f135677f618529449334a612" + "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612", - "reference": "875e90aeea2777b6f135677f618529449334a612", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/32a9da87d7b3245e09ac426c83d334ae9f06f80f", + "reference": "32a9da87d7b3245e09ac426c83d334ae9f06f80f", "shasum": "" }, "require": { @@ -5635,9 +5697,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -5676,7 +5735,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.29.0" }, "funding": [ { @@ -5692,20 +5751,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.28.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92" + "reference": "bc45c394692b948b4d383a08d7753968bed9a83d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", - "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/bc45c394692b948b4d383a08d7753968bed9a83d", + "reference": "bc45c394692b948b4d383a08d7753968bed9a83d", "shasum": "" }, "require": { @@ -5716,9 +5775,6 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" @@ -5760,7 +5816,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.29.0" }, "funding": [ { @@ -5776,20 +5832,20 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-01-29T20:11:03+00:00" }, { "name": "symfony/process", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "191703b1566d97a5425dc969e4350d32b8ef17aa" + "reference": "710e27879e9be3395de2b98da3f52a946039f297" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/191703b1566d97a5425dc969e4350d32b8ef17aa", - "reference": "191703b1566d97a5425dc969e4350d32b8ef17aa", + "url": "https://api.github.com/repos/symfony/process/zipball/710e27879e9be3395de2b98da3f52a946039f297", + "reference": "710e27879e9be3395de2b98da3f52a946039f297", "shasum": "" }, "require": { @@ -5821,7 +5877,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.0" + "source": "https://github.com/symfony/process/tree/v6.4.4" }, "funding": [ { @@ -5837,20 +5893,20 @@ "type": "tidelift" } ], - "time": "2023-11-17T21:06:49+00:00" + "time": "2024-02-20T12:31:00+00:00" }, { "name": "symfony/stopwatch", - "version": "v6.4.0", + "version": "v6.4.3", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2" + "reference": "416596166641f1f728b0a64f5b9dd07cceb410c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2", - "reference": "fc47f1015ec80927ff64ba9094dfe8b9d48fe9f2", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/416596166641f1f728b0a64f5b9dd07cceb410c1", + "reference": "416596166641f1f728b0a64f5b9dd07cceb410c1", "shasum": "" }, "require": { @@ -5883,7 +5939,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v6.4.0" + "source": "https://github.com/symfony/stopwatch/tree/v6.4.3" }, "funding": [ { @@ -5899,20 +5955,20 @@ "type": "tidelift" } ], - "time": "2023-02-16T10:14:28+00:00" + "time": "2024-01-23T14:35:58+00:00" }, { "name": "symfony/string", - "version": "v6.4.0", + "version": "v6.4.4", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "b45fcf399ea9c3af543a92edf7172ba21174d809" + "reference": "4e465a95bdc32f49cf4c7f07f751b843bbd6dcd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/b45fcf399ea9c3af543a92edf7172ba21174d809", - "reference": "b45fcf399ea9c3af543a92edf7172ba21174d809", + "url": "https://api.github.com/repos/symfony/string/zipball/4e465a95bdc32f49cf4c7f07f751b843bbd6dcd9", + "reference": "4e465a95bdc32f49cf4c7f07f751b843bbd6dcd9", "shasum": "" }, "require": { @@ -5969,7 +6025,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.0" + "source": "https://github.com/symfony/string/tree/v6.4.4" }, "funding": [ { @@ -5985,20 +6041,20 @@ "type": "tidelift" } ], - "time": "2023-11-28T20:41:49+00:00" + "time": "2024-02-01T13:16:41+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96" + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b2ad5003ca10d4ee50a12da31de12a5774ba6b96", - "reference": "b2ad5003ca10d4ee50a12da31de12a5774ba6b96", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", "shasum": "" }, "require": { @@ -6027,7 +6083,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.2" + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" }, "funding": [ { @@ -6035,7 +6091,58 @@ "type": "github" } ], - "time": "2023-11-20T00:12:19+00:00" + "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "timeweb/phpstan-enum", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/timeweb/phpstan-enum.git", + "reference": "72f54c981431039ee194ffdf46e8340fea49195a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/timeweb/phpstan-enum/zipball/72f54c981431039ee194ffdf46e8340fea49195a", + "reference": "72f54c981431039ee194ffdf46e8340fea49195a", + "shasum": "" + }, + "require": { + "myclabs/php-enum": "^1.2", + "php": "^7.1|^8.0", + "phpstan/phpstan": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.0|^9.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "Timeweb\\PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Enum class reflection extension for PHPStan", + "keywords": [ + "PHPStan", + "enum" + ], + "support": { + "issues": "https://github.com/timeweb/phpstan-enum/issues", + "source": "https://github.com/timeweb/phpstan-enum/tree/v3.1.1" + }, + "time": "2022-10-17T05:55:05+00:00" }, { "name": "yoast/phpunit-polyfills", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index db4be25..44ba6fc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -39,3 +39,4 @@ parameters: includes: - phpstan-baseline.neon - phpstan-ignores.neon + - app/vendor/timeweb/phpstan-enum/extension.neon diff --git a/tests/Controllers/ZipControllerTest.php b/tests/Controllers/ZipControllerTest.php index ad99aeb..37aa87e 100644 --- a/tests/Controllers/ZipControllerTest.php +++ b/tests/Controllers/ZipControllerTest.php @@ -17,7 +17,6 @@ class ZipControllerTest extends TestCase { $controller = new ZipController( $this->config, - $this->cache, new Finder, $this->container->get(TranslatorInterface::class) ); @@ -30,16 +29,13 @@ class ZipControllerTest extends TestCase $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('application/zip', finfo_buffer( - finfo_open(), (string) $response->getBody(), FILEINFO_MIME_TYPE - )); + $this->assertEquals('application/zip', $response->getHeader('Content-Type')[0]); } public function test_it_returns_a_404_error_when_not_found(): void { $controller = new ZipController( $this->config, - $this->cache, new Finder, $this->container->get(TranslatorInterface::class) ); @@ -59,7 +55,6 @@ class ZipControllerTest extends TestCase $this->container->set('zip_downloads', false); $controller = new ZipController( $this->config, - $this->cache, new Finder, $this->container->get(TranslatorInterface::class) ); diff --git a/tests/TemporaryFileTest.php b/tests/TemporaryFileTest.php deleted file mode 100644 index 3273ef0..0000000 --- a/tests/TemporaryFileTest.php +++ /dev/null @@ -1,38 +0,0 @@ -filePath('app/cache')); - - $this->assertFileExists((string) $tempFile); - } - - public function test_it_can_write_to_and_read_from_a_temporary_file(): void - { - $tempFile = new TemporaryFile($this->filePath('app/cache')); - - $this->assertFileIsReadable((string) $tempFile); - $this->assertFileIsWritable((string) $tempFile); - - file_put_contents((string) $tempFile, 'Test file; please ignore'); - - $this->assertEquals('Test file; please ignore', $tempFile->getContents()); - } - - public function test_it_removes_the_underlying_file_on_destruction(): void - { - $tempFile = new TemporaryFile($this->filePath('app/cache')); - $filePath = (string) $tempFile; - - unset($tempFile); - - $this->assertFalse(is_file($filePath)); - } -} From 51a06b5cc08ccf361095fd349c89140e96b1cafd Mon Sep 17 00:00:00 2001 From: Arno DUBOIS Date: Fri, 26 May 2023 19:33:47 +0000 Subject: [PATCH 3/6] Implement proper Zip64 estimation support --- app/src/Controllers/ZipController.php | 86 +++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 12 deletions(-) diff --git a/app/src/Controllers/ZipController.php b/app/src/Controllers/ZipController.php index e1965a4..09537a1 100644 --- a/app/src/Controllers/ZipController.php +++ b/app/src/Controllers/ZipController.php @@ -5,13 +5,11 @@ namespace App\Controllers; use App\CallbackStream; use App\Config; use App\Support\Str; -use App\TemporaryFile; use Psr\Http\Message\ResponseInterface; use Slim\Psr7\Request; use Slim\Psr7\Response; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; -use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Translation\TranslatorInterface; use ZipStream\Option\Archive; use ZipStream\Option\File; @@ -23,7 +21,6 @@ class ZipController /** Create a new ZipHandler object. */ public function __construct( private Config $config, - private CacheInterface $cache, private Finder $finder, private TranslatorInterface $translator ) {} @@ -44,9 +41,9 @@ class ZipController $this->generateFileName($path) )) ->withHeader('X-Accel-Buffering', 'no'); - + $files = $this->finder->in($path)->files(); - + $response = $this->augmentHeadersWithEstimatedSize($response, $path, $files); return $response->withBody(new CallbackStream(function () use ($path, $files) { @@ -54,21 +51,31 @@ class ZipController })); } - /** Create a zip stream from a directory. */ + /** Create a zip stream from a directory. + * + * @throws \ZipStream\Exception\FileNotFoundException + * @throws \ZipStream\Exception\FileNotReadableException + * @throws \ZipStream\Exception\OverflowException + */ protected function createZip(string $path, Finder $files): void { $compressionMethod = $this->config->get('zip_compress') ? Method::DEFLATE() : Method::STORE(); $zipStreamOptions = new Archive(); $zipStreamOptions->setLargeFileMethod($compressionMethod); + $zipStreamOptions->setSendHttpHeaders(false); $zipStreamOptions->setFlushOutput(true); + $zipStreamOptions->setEnableZip64(true); $zip = new ZipStream(null, $zipStreamOptions); - $fileOption = new File(); - $fileOption->setMethod($compressionMethod); - + foreach ($files as $file) { + $fileOption = new File(); + $fileOption->setMethod($compressionMethod); + $fileOption->setSize($file->getSize()); + $creationTime = $file->getMTime(); + $fileOption->setTime(new \DateTime("@$creationTime")); $zip->addFileFromPath($this->stripPath($file, $path), (string) $file->getRealPath(), $fileOption); } @@ -77,16 +84,71 @@ class ZipController protected function augmentHeadersWithEstimatedSize(Response $response, string $path, Finder $files): Response { - $estimate = 22; - if (!$this->config->get('zip_compress')) { + if (! $this->config->get('zip_compress')) { + $totalSize = 0; + $filesMeta = []; foreach ($files as $file) { - $estimate += 76 + 2 * strlen($this->stripPath($file, $path)) + $file->getSize(); + $fileSize = $file->getSize(); + $totalSize += $fileSize; + $filesMeta[] = [strlen($this->stripPath($file, $path)), $fileSize]; } + # If there is more than 4 GB or 2^16 files, it will be a ZIP64, changing the estimation method + if ($totalSize >= 2^32 || count($filesMeta) >= 0xFFFF) { + $estimate = $this->calculateZip64Size($filesMeta); + } else { + $estimate = $this->calculateZipSize($filesMeta); + } + $response = $response->withHeader('Content-Length', (string) $estimate); } + return $response; } + protected function calculateZipSize(Array $filesMeta): int + { + $estimate = 22; + foreach ($filesMeta as $fileMeta) { + $estimate += 76 + 2 * $fileMeta[0] + $fileMeta[1]; + } + return $estimate; + } + + protected function calculateZip64Size(Array $filesMeta): int + { + # Size of the CDR calculated by ZipStream is always 44 + 12 for signature and the size itself + $estimate = 56; + # Size of the CRD locator (always 20 according to the spec) + $estimate += 20; + foreach ($filesMeta as $fileMeta) { + # This is not different from standard Zip + $estimate += 76 + 2 * $fileMeta[0] + $fileMeta[1]; + # This is where it gets funky + $zip64ExtraBlockSize = 0; + if ($fileMeta[1] >= 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 ($estimate >= 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 is in UTF-8, then ZipStream will add the two remaining fields with special values + if (mb_check_encoding($filesMeta[0], 'UTF-8')) { + $zip64ExtraBlockSize += 4; + } + + $estimate += $zip64ExtraBlockSize; + } + return $estimate; + } + /** Return the path to a file with the preceding root path stripped. */ protected function stripPath(SplFileInfo $file, string $path): string { From 24d0d2f01335f27966dd9412d86ea5c5232e24a7 Mon Sep 17 00:00:00 2001 From: Arno DUBOIS Date: Sat, 27 May 2023 20:02:56 +0000 Subject: [PATCH 4/6] Fix Zip64 calculation --- app/src/Controllers/ZipController.php | 123 ++++++++++++++++++-------- 1 file changed, 84 insertions(+), 39 deletions(-) diff --git a/app/src/Controllers/ZipController.php b/app/src/Controllers/ZipController.php index 09537a1..2b09f1a 100644 --- a/app/src/Controllers/ZipController.php +++ b/app/src/Controllers/ZipController.php @@ -69,13 +69,17 @@ class ZipController $zip = new ZipStream(null, $zipStreamOptions); - foreach ($files as $file) { $fileOption = new File(); $fileOption->setMethod($compressionMethod); $fileOption->setSize($file->getSize()); - $creationTime = $file->getMTime(); - $fileOption->setTime(new \DateTime("@$creationTime")); + + 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 + } $zip->addFileFromPath($this->stripPath($file, $path), (string) $file->getRealPath(), $fileOption); } @@ -86,74 +90,105 @@ class ZipController { if (! $this->config->get('zip_compress')) { $totalSize = 0; + $hasUtf8 = false; $filesMeta = []; foreach ($files as $file) { $fileSize = $file->getSize(); $totalSize += $fileSize; - $filesMeta[] = [strlen($this->stripPath($file, $path)), $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 >= 2^32 || count($filesMeta) >= 0xFFFF) { + // 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); } return $response; } - protected function calculateZipSize(Array $filesMeta): int + protected function calculateZipSize(array $filesMeta): int { $estimate = 22; foreach ($filesMeta as $fileMeta) { - $estimate += 76 + 2 * $fileMeta[0] + $fileMeta[1]; + $estimate += 76 + 2 * strlen($fileMeta[0]) + $fileMeta[1]; } + return $estimate; } - protected function calculateZip64Size(Array $filesMeta): int + protected function calculateZip64Size(array $filesMeta): int { - # Size of the CDR calculated by ZipStream is always 44 + 12 for signature and the size itself - $estimate = 56; - # Size of the CRD locator (always 20 according to the spec) - $estimate += 20; + $estimate = 0; foreach ($filesMeta as $fileMeta) { - # This is not different from standard Zip - $estimate += 76 + 2 * $fileMeta[0] + $fileMeta[1]; - # This is where it gets funky - $zip64ExtraBlockSize = 0; - if ($fileMeta[1] >= 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 ($estimate >= 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 is in UTF-8, then ZipStream will add the two remaining fields with special values - if (mb_check_encoding($filesMeta[0], 'UTF-8')) { - $zip64ExtraBlockSize += 4; - } - - $estimate += $zip64ExtraBlockSize; + $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 { $pattern = sprintf( - '/^%s%s?/', preg_quote($path, '/'), preg_quote(DIRECTORY_SEPARATOR, '/') + '/^%s%s?/', + preg_quote($path, '/'), + preg_quote(DIRECTORY_SEPARATOR, '/') ); return (string) preg_replace($pattern, '', $file->getPathname()); @@ -166,4 +201,14 @@ 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); + } } From 1bea4e2a0a0b6c4ad49ade3b606f38d730646371 Mon Sep 17 00:00:00 2001 From: Arno DUBOIS Date: Sun, 17 Dec 2023 17:30:43 +0000 Subject: [PATCH 5/6] Fix PHP coding standards --- app/src/CallbackStream.php | 8 ++------ app/src/Controllers/ZipController.php | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/CallbackStream.php b/app/src/CallbackStream.php index 6afa8ef..e2e2296 100644 --- a/app/src/CallbackStream.php +++ b/app/src/CallbackStream.php @@ -40,9 +40,7 @@ class CallbackStream implements StreamInterface } /** Closes the stream and any underlying resources. */ - public function close() - { - } + public function close() {} /** * Separates any underlying resources from the stream. @@ -110,9 +108,7 @@ class CallbackStream implements StreamInterface * offset bytes SEEK_CUR: Set position to current location plus offset * SEEK_END: Set position to end-of-stream plus offset. */ - public function seek($offset, $whence = SEEK_SET): void - { - } + public function seek($offset, $whence = SEEK_SET): void {} /** * Seek to the beginning of the stream. diff --git a/app/src/Controllers/ZipController.php b/app/src/Controllers/ZipController.php index 2b09f1a..830c4ae 100644 --- a/app/src/Controllers/ZipController.php +++ b/app/src/Controllers/ZipController.php @@ -61,7 +61,7 @@ class ZipController { $compressionMethod = $this->config->get('zip_compress') ? Method::DEFLATE() : Method::STORE(); - $zipStreamOptions = new Archive(); + $zipStreamOptions = new Archive; $zipStreamOptions->setLargeFileMethod($compressionMethod); $zipStreamOptions->setSendHttpHeaders(false); $zipStreamOptions->setFlushOutput(true); @@ -70,7 +70,7 @@ class ZipController $zip = new ZipStream(null, $zipStreamOptions); foreach ($files as $file) { - $fileOption = new File(); + $fileOption = new File; $fileOption->setMethod($compressionMethod); $fileOption->setSize($file->getSize()); From bff9e11b5000c8bb2685b9ef9cc11be0dc8c5b5c Mon Sep 17 00:00:00 2001 From: Arno DUBOIS Date: Tue, 12 Mar 2024 00:30:32 +0000 Subject: [PATCH 6/6] 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