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",