Added the ability to download a directory as a zip archive

This commit is contained in:
Chris Kankiewicz
2020-02-14 14:06:33 -07:00
parent 1f525f5d60
commit 88f05da6aa
12 changed files with 263 additions and 24 deletions

View File

@@ -4,7 +4,11 @@ LABEL maintainer="Chris Kankiewicz <Chris@ChrisKankiewicz.com>"
COPY .docker/apache/config/000-default.conf /etc/apache2/sites-available/000-default.conf
COPY .docker/php/config/php.ini /usr/local/etc/php/php.ini
RUN apt-get update && apt-get install --assume-yes libzip-dev \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install zip && pecl install xdebug && docker-php-ext-enable xdebug
RUN a2enmod rewrite
RUN pecl install xdebug && docker-php-ext-enable xdebug
WORKDIR /var/www/html

View File

@@ -46,7 +46,10 @@ class AppManager
{
$this->registerProviders();
$app = Bridge::create($this->container);
$app->add(new Middlewares\Expires(['text/json' => '+1 hour']));
$app->add(new Middlewares\Expires([
'application/zip' => '+1 hour',
'text/json' => '+1 hour',
]));
return $app;
}

View File

@@ -39,6 +39,9 @@ class IndexController
case array_key_exists('search', $request->getQueryParams()):
return $this->container->call(Handlers\SearchHandler::class, [$request, $response]);
case array_key_exists('zip', $request->getQueryParams()):
return $this->container->call(Handlers\ZipHandler::class, [$request, $response]);
default:
return $this->container->call(Handlers\DirectoryHandler::class, [$request, $response]);
}

View File

@@ -3,6 +3,7 @@
namespace App\Handlers;
use PHLAK\Config\Config;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
@@ -43,7 +44,7 @@ class DirectoryHandler
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(Request $request, Response $response)
public function __invoke(Request $request, Response $response): ResponseInterface
{
$path = $request->getQueryParams()['dir'] ?? '.';

View File

@@ -4,6 +4,7 @@ namespace App\Handlers;
use DI\Container;
use PHLAK\Config\Config;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use SplFileInfo;
@@ -33,8 +34,10 @@ class FileInfoHandler
*
* @param \Slim\Psr7\Request $request
* @param \Slim\Psr7\Response $response
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(Request $request, Response $response)
public function __invoke(Request $request, Response $response): ResponseInterface
{
$path = $request->getQueryParams()['info'];

View File

@@ -2,6 +2,7 @@
namespace App\Handlers;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Slim\Views\Twig;
@@ -35,7 +36,7 @@ class SearchHandler
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(Request $request, Response $response)
public function __invoke(Request $request, Response $response): ResponseInterface
{
$search = $request->getQueryParams()['search'];

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Handlers;
use App\TemporaryFile;
use DI\Container;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Symfony\Component\Finder\Finder;
use Tightenco\Collect\Support\Collection;
use ZipArchive;
class ZipHandler
{
/** @var Container The application container */
protected $container;
/** @var Finder The Finder Component */
protected $finder;
/**
* Create a new ZipHandler object.
*
* @param \DI\Container $container
* @param \PhpCsFixer\Finder $finder
*/
public function __construct(Container $container, Finder $finder)
{
$this->container = $container;
$this->finder = $finder;
}
/**
* Invoke the ZipHandler.
*
* @param \Slim\Psr7\Request $request
* @param \Slim\Psr7\Response $response
*
* @return \Psr\Http\Message\ResponseInterface
*/
public function __invoke(Request $request, Response $response): ResponseInterface
{
$path = $request->getQueryParams()['zip'];
if (! realpath($path)) {
return $response->withStatus(404, 'File not found');
}
$tempFile = new TemporaryFile(
$this->container->get('base_path') . '/app/cache'
);
$zip = new ZipArchive;
$zip->open((string) $tempFile, ZipArchive::CREATE | ZipArchive::OVERWRITE);
foreach ($this->finder->in($path) as $file) {
if ($file->isFile()) {
$zip->addFile($file->getRealPath(), $file->getPathname());
}
}
$zip->close();
$response->getBody()->write($tempFile->getContents());
return $response->withHeader('Content-Type', 'application/zip')
->withHeader('Content-Disposition', sprintf(
'attachment; filename="%s.zip"',
Collection::make(explode('/', $path))->last()
));
}
}

46
app/src/TemporaryFile.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace App;
class TemporaryFile
{
/** @var string Path to the temporary file */
protected $path;
/**
* Create a new TemporaryFile object.
*
* @param string $dir
* @param string $prefix
*/
public function __construct(string $dir, string $prefix = '')
{
$this->path = tempnam($dir, $prefix);
}
/** Destroy this TemporaryFile object. */
public function __destruct()
{
unlink($this->path);
}
/**
* Get the path to the temporary file.
*
* @return string
*/
public function __toString(): string
{
return $this->path;
}
/**
* Get the raw contents of the file.
*
* @return string
*/
public function getContents(): string
{
return file_get_contents($this->path);
}
}

View File

@@ -1,6 +1,6 @@
<header id="header" class="bg-blue-600 shadow sticky top-0">
<div class="container flex flex-col justify-between items-center mx-auto p-4 md:flex-row">
<div class="font-mono text-white text-sm tracking-tight mb-2 md:my-1">
<div class="container flex flex-coljustify-between items-center mx-auto p-4 md:flex-row">
<div class="flex-grow font-mono text-white text-sm tracking-tight mb-2 md:my-1">
<a href="." class="hover:underline">Home</a>
{% if path %}
@@ -10,23 +10,31 @@
{% endif %}
</div>
<form action="." method="get" id="search" class="group relative block w-full bg-blue-700 rounded-full shadow-inner md:w-4/12 md:-my-2">
<input type="text" name="search" placeholder="Search this directory..." value="{{ search }}"
class="bg-transparent placeholder-gray-900 text-white w-full px-10 py-2"
v-model="search"
>
<div class="flex items-center absolute left-0 inset-y-0 ml-2 pointer-events-none">
<div class="flex justify-center items-center text-blue-900 w-6 h-6">
<i class="fas fa-search fa-fw"></i>
</div>
</div>
<div class="flex items-center absolute right-0 inset-y-0 mr-2" v-show="search">
<a href="." class="flex justify-center items-center rounded-full text-blue-900 w-6 h-6 hover:bg-red-700 hover:text-white hover:shadow">
<i class="fas fa-times"></i>
<div class="flex justify-end items-center">
{% if not search %}
<a href="?zip={{ path }}" title="Download this Directory" class="inline-block text-white mx-2">
<i class="fas fa-download fa-lg"></i>
</a>
</div>
</form>
{% endif %}
<form action="." method="get" id="search" class="flex-grow group relative block bg-blue-700 rounded-full shadow-inner">
<input type="text" name="search" placeholder="Search..." value="{{ search }}"
class="bg-transparent placeholder-gray-900 text-white w-full px-10 py-2"
v-model="search"
>
<div class="flex items-center absolute left-0 inset-y-0 ml-2 pointer-events-none">
<div class="flex justify-center items-center text-blue-900 w-6 h-6">
<i class="fas fa-search fa-fw"></i>
</div>
</div>
<div class="flex items-center absolute right-0 inset-y-0 mr-2" v-show="search">
<a href="." class="flex justify-center items-center rounded-full text-blue-900 w-6 h-6 hover:bg-red-700 hover:text-white hover:shadow">
<i class="fas fa-times"></i>
</a>
</div>
</form>
</div>
</div>
</header>

View File

@@ -43,6 +43,22 @@ class IndexControllerTest extends TestCase
$controller($request, $response);
}
public function test_it_handles_a_zip_request(): void
{
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['zip' => 'subdir']);
$container = $this->createMock(Container::class);
$container->expects($this->once())->method('call')->with(
Handlers\ZipHandler::class,
[$request, $response = new Response]
);
$controller = new IndexController($container);
$controller($request, $response);
}
public function test_it_handles_a_directory_request(): void
{
$request = $this->createMock(Request::class);

View File

@@ -0,0 +1,44 @@
<?php
namespace Tests\Handlers;
use App\Handlers\ZipHandler;
use Psr\Http\Message\ResponseInterface;
use Slim\Psr7\Request;
use Slim\Psr7\Response;
use Symfony\Component\Finder\Finder;
use Tests\TestCase;
class ZipHandlerTest extends TestCase
{
public function test_it_returns_a_successful_response_for_a_zip_request(): void
{
$handler = new ZipHandler($this->container, new Finder);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['zip' => 'subdir']);
chdir($this->filePath('.'));
$response = $handler($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('application/zip', finfo_buffer(
finfo_open(), (string) $response->getBody(), FILEINFO_MIME_TYPE
));
}
public function test_it_returns_a_404_error_when_not_found(): void
{
$handler = new ZipHandler($this->container, new Finder);
$request = $this->createMock(Request::class);
$request->method('getQueryParams')->willReturn(['zip' => '404']);
chdir($this->filePath('.'));
$response = $handler($request, new Response);
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(404, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Tests;
use App\TemporaryFile;
class TemporaryFileTest extends TestCase
{
public function test_it_can_create_a_temporary_file(): void
{
$tempFile = new TemporaryFile(sys_get_temp_dir());
$this->assertFileExists((string) $tempFile);
}
public function test_it_can_write_to_and_read_from_a_temporary_file(): void
{
$tempFile = new TemporaryFile(sys_get_temp_dir());
$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(sys_get_temp_dir());
$filePath = (string) $tempFile;
unset($tempFile);
$this->assertFileNotExists($filePath);
}
}