diff --git a/Dockerfile b/Dockerfile index 0ff9ce0..9ec6399 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,11 @@ LABEL maintainer="Chris Kankiewicz " 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 diff --git a/app/src/Bootstrap/AppManager.php b/app/src/Bootstrap/AppManager.php index 73ee16c..feca7e9 100644 --- a/app/src/Bootstrap/AppManager.php +++ b/app/src/Bootstrap/AppManager.php @@ -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; } diff --git a/app/src/Controllers/IndexController.php b/app/src/Controllers/IndexController.php index 8c746f5..9ead43a 100644 --- a/app/src/Controllers/IndexController.php +++ b/app/src/Controllers/IndexController.php @@ -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]); } diff --git a/app/src/Handlers/DirectoryHandler.php b/app/src/Handlers/DirectoryHandler.php index 444e55b..34d3885 100644 --- a/app/src/Handlers/DirectoryHandler.php +++ b/app/src/Handlers/DirectoryHandler.php @@ -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'] ?? '.'; diff --git a/app/src/Handlers/FileInfoHandler.php b/app/src/Handlers/FileInfoHandler.php index 3538c5d..0cd447c 100644 --- a/app/src/Handlers/FileInfoHandler.php +++ b/app/src/Handlers/FileInfoHandler.php @@ -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']; diff --git a/app/src/Handlers/SearchHandler.php b/app/src/Handlers/SearchHandler.php index 40cd167..ae21b8c 100644 --- a/app/src/Handlers/SearchHandler.php +++ b/app/src/Handlers/SearchHandler.php @@ -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']; diff --git a/app/src/Handlers/ZipHandler.php b/app/src/Handlers/ZipHandler.php new file mode 100644 index 0000000..52205b4 --- /dev/null +++ b/app/src/Handlers/ZipHandler.php @@ -0,0 +1,73 @@ +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() + )); + } +} diff --git a/app/src/TemporaryFile.php b/app/src/TemporaryFile.php new file mode 100644 index 0000000..1b9c02b --- /dev/null +++ b/app/src/TemporaryFile.php @@ -0,0 +1,46 @@ +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); + } +} diff --git a/app/views/components/header.twig b/app/views/components/header.twig index 91090f6..87dcff0 100644 --- a/app/views/components/header.twig +++ b/app/views/components/header.twig @@ -1,6 +1,6 @@ diff --git a/tests/Controllers/IndexControllerTest.php b/tests/Controllers/IndexControllerTest.php index c55987c..da1294d 100644 --- a/tests/Controllers/IndexControllerTest.php +++ b/tests/Controllers/IndexControllerTest.php @@ -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); diff --git a/tests/Handlers/ZipHandlerTest.php b/tests/Handlers/ZipHandlerTest.php new file mode 100644 index 0000000..f737601 --- /dev/null +++ b/tests/Handlers/ZipHandlerTest.php @@ -0,0 +1,44 @@ +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()); + } +} diff --git a/tests/TemporaryFileTest.php b/tests/TemporaryFileTest.php new file mode 100644 index 0000000..9a21fe5 --- /dev/null +++ b/tests/TemporaryFileTest.php @@ -0,0 +1,37 @@ +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); + } +}