Merge pull request #1119 from Arno500/master

feat: Add Zip streaming
This commit is contained in:
Chris Kankiewicz
2024-03-12 09:59:41 -07:00
committed by GitHub
8 changed files with 694 additions and 313 deletions

View File

@@ -67,6 +67,14 @@ return [
*/
'zip_downloads' => env('ZIP_DOWNLOADS', true),
/**
* 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
*/
'zip_compress' => env('ZIP_COMPRESS', false),
/**
* Your Google analytics tracking ID.
*

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

@@ -0,0 +1,215 @@
<?php
/**
* @copyright Copyright (c) 2015 Matthew Weier O'Phinney (https://mwop.net)
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2-Clause
*
* @see 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. */
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. */
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.
*
* @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.
*
* @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.
*/
public function seek($offset, $whence = SEEK_SET): void {}
/**
* 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).
*
* @see 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
*
* @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.
*
* @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.
*
* @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.
*
* @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.
*/
public function getMetadata($key = null)
{
if ($key === null) {
return [];
}
return null;
}
}

View File

@@ -2,29 +2,35 @@
namespace App\Controllers;
use App\CallbackStream;
use App\Config;
use App\Support\Str;
use App\TemporaryFile;
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\Cache\CacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use ZipArchive;
use ZipStream\CompressionMethod;
use ZipStream\OperationMode;
use ZipStream\ZipStream;
class ZipController
{
/** Create a new ZipHandler object. */
public function __construct(
private Config $config,
private CacheInterface $cache,
private Finder $finder,
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'];
@@ -33,41 +39,80 @@ 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();
$zip = $this->createZip($path, $files);
$size = $zip->finish();
$response = $this
->augmentHeadersWithEstimatedSize($response, $size)
->withBody(new CallbackStream(function () use ($zip) {
$zip->executeSimulation();
}));
return $response;
}
/** Create a zip file from a directory. */
protected function createZip(string $path): TemporaryFile
/** Create a zip stream from a directory.
*
* @throws \ZipStream\Exception\FileNotFoundException
* @throws \ZipStream\Exception\FileNotReadableException
* @throws Exception
*/
protected function createZip(string $path, Finder $files): ZipStream
{
$zip = new ZipArchive;
$zip->open((string) $tempFile = new TemporaryFile(
$this->config->get('cache_path')
), ZipArchive::CREATE | ZipArchive::OVERWRITE);
$compressionMethod = $this->config->get('zip_compress') ? CompressionMethod::DEFLATE : CompressionMethod::STORE;
foreach ($this->finder->in($path)->files() as $file) {
$zip->addFile((string) $file->getRealPath(), $this->stripPath($file, $path));
$zip = new ZipStream(
sendHttpHeaders: false,
operationMode: OperationMode::SIMULATE_LAX
);
foreach ($files as $file) {
$modifiedTime = null;
try {
$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(),
compressionMethod: $compressionMethod,
lastModificationDateTime: $modifiedTime,
exactSize: $file->getSize()
);
}
$zip->close();
return $zip;
}
return $tempFile;
protected function augmentHeadersWithEstimatedSize(Response $response, int $size): Response
{
$response = $response->withHeader('Content-Length', (string) $size);
return $response;
}
/** 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());

View File

@@ -1,33 +0,0 @@
<?php
namespace App;
class TemporaryFile
{
/** @var string Path to the temporary file */
private string $path;
/** Create a new TemporaryFile object. */
public function __construct(string $dir, string $prefix = '')
{
$this->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);
}
}

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

612
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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)
);

View File

@@ -1,38 +0,0 @@
<?php
namespace Tests;
use App\TemporaryFile;
/** @covers \App\TemporaryFile */
class TemporaryFileTest extends TestCase
{
public function test_it_can_create_a_temporary_file(): void
{
$tempFile = new TemporaryFile($this->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));
}
}