mirror of
https://github.com/DirectoryLister/DirectoryLister.git
synced 2025-08-20 21:01:38 +02:00
@@ -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
215
app/src/CallbackStream.php
Normal 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;
|
||||
}
|
||||
}
|
@@ -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());
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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
612
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
);
|
||||
|
@@ -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));
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user