Implement Zip streaming

This commit is contained in:
Arno DUBOIS
2023-05-23 00:47:26 +00:00
parent 10795b1011
commit 88a1e6f8c2
5 changed files with 371 additions and 20 deletions

View File

@@ -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.
*

238
app/src/CallbackStream.php Normal file
View File

@@ -0,0 +1,238 @@
<?php
/**
* @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
*/
namespace App;
use Psr\Http\Message\StreamInterface;
/**
* Callback-based stream implementation.
*
* Wraps a callback, and invokes it in order to stream it.
*
* Only one invocation is allowed; multiple invocations will return an empty
* string for the second and subsequent calls.
*/
class CallbackStream implements StreamInterface
{
/**
* @var callable|null
*/
private $callback;
/**
* Whether the callback has been previously invoked.
*
* @var bool
*/
private bool $called = false;
/**
* @param callable $callback The callback function that echos the body content
*/
public function __construct(callable $callback)
{
$this->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;
}
}

View File

@@ -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. */

View File

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

83
composer.lock generated
View File

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