diff --git a/bin/wkhtmltoimage b/bin/wkhtmltoimage
new file mode 100755
index 00000000..8acfa45a
Binary files /dev/null and b/bin/wkhtmltoimage differ
diff --git a/composer.json b/composer.json
index 88c74604..923d6f32 100644
--- a/composer.json
+++ b/composer.json
@@ -28,10 +28,12 @@
"guzzlehttp/guzzle": "^6.2",
"symfony/console": "^3.0",
"symfony/process": "^3.0",
+ "symfony/filesystem": "^3.0",
"firebase/php-jwt": "^4.0",
"monolog/monolog": "^1.21",
"theorchard/monolog-cascade": "^0.4",
- "endroid/qrcode": "^1.7"
+ "endroid/qrcode": "^1.7",
+ "mikehaertl/phpwkhtmltopdf": "^2.2"
},
"require-dev": {
"phpunit/phpunit": "^5.0",
diff --git a/config/autoload/phpwkhtmltopdf.global.php b/config/autoload/phpwkhtmltopdf.global.php
new file mode 100644
index 00000000..f7c29f16
--- /dev/null
+++ b/config/autoload/phpwkhtmltopdf.global.php
@@ -0,0 +1,11 @@
+ [
+ 'images' => [
+ 'binary' => 'bin/wkhtmltoimage',
+ 'type' => 'jpg',
+ ],
+ ],
+
+];
diff --git a/config/autoload/preview-generation.global.php b/config/autoload/preview-generation.global.php
new file mode 100644
index 00000000..b4f14da3
--- /dev/null
+++ b/config/autoload/preview-generation.global.php
@@ -0,0 +1,8 @@
+ [
+ 'files_location' => 'data/cache',
+ ],
+
+];
diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php
index 31c4e460..8bd88607 100644
--- a/module/CLI/config/cli.config.php
+++ b/module/CLI/config/cli.config.php
@@ -10,6 +10,7 @@ return [
Command\Shortcode\ResolveUrlCommand::class,
Command\Shortcode\ListShortcodesCommand::class,
Command\Shortcode\GetVisitsCommand::class,
+ Command\Shortcode\GeneratePreviewCommand::class,
Command\Visit\ProcessVisitsCommand::class,
Command\Config\GenerateCharsetCommand::class,
Command\Config\GenerateSecretCommand::class,
diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php
index ebc607c8..00e56607 100644
--- a/module/CLI/config/dependencies.config.php
+++ b/module/CLI/config/dependencies.config.php
@@ -14,6 +14,7 @@ return [
Command\Shortcode\ResolveUrlCommand::class => AnnotatedFactory::class,
Command\Shortcode\ListShortcodesCommand::class => AnnotatedFactory::class,
Command\Shortcode\GetVisitsCommand::class => AnnotatedFactory::class,
+ Command\Shortcode\GeneratePreviewCommand::class => AnnotatedFactory::class,
Command\Visit\ProcessVisitsCommand::class => AnnotatedFactory::class,
Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class,
Command\Config\GenerateSecretCommand::class => AnnotatedFactory::class,
diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo
index 1f88545f..f0702c6d 100644
Binary files a/module/CLI/lang/es.mo and b/module/CLI/lang/es.mo differ
diff --git a/module/CLI/lang/es.po b/module/CLI/lang/es.po
index 968701ea..d5e49206 100644
--- a/module/CLI/lang/es.po
+++ b/module/CLI/lang/es.po
@@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
-"POT-Creation-Date: 2016-08-07 20:16+0200\n"
-"PO-Revision-Date: 2016-08-07 20:18+0200\n"
+"POT-Creation-Date: 2016-08-18 17:24+0200\n"
+"PO-Revision-Date: 2016-08-18 17:26+0200\n"
"Last-Translator: Alejandro Celaya \n"
"Language-Team: \n"
"Language: es_ES\n"
@@ -68,7 +68,35 @@ msgstr ""
msgid "Character set:"
msgstr "Grupo de caracteres:"
-#, fuzzy
+msgid ""
+"Generates a random secret string that can be used for JWT token encryption"
+msgstr ""
+"Genera una cadena de caracteres aleatoria que puede ser usada para cifrar "
+"tokens JWT"
+
+msgid "Secret key:"
+msgstr "Clave secreta:"
+
+msgid ""
+"Processes and generates the previews for every URL, improving performance "
+"for later web requests."
+msgstr ""
+"Procesa y genera las vistas previas para cada URL, mejorando el rendimiento "
+"para peticiones web posteriores."
+
+msgid "Finished processing all URLs"
+msgstr "Finalizado el procesado de todas las URLs"
+
+#, php-format
+msgid "Processing URL %s..."
+msgstr "Procesando URL %s..."
+
+msgid " Success!"
+msgstr "¡Correcto!"
+
+msgid "Error"
+msgstr "Error"
+
msgid "Generates a short code for provided URL and returns the short URL"
msgstr ""
"Genera un código corto para la URL proporcionada y devuelve la URL acortada"
@@ -149,22 +177,6 @@ msgstr "Has alcanzado la última página"
msgid "Continue with page"
msgstr "Continuar con la página"
-msgid "Processes visits where location is not set yet"
-msgstr "Procesa las visitas donde la localización no ha sido establecida aún"
-
-msgid "Processing IP"
-msgstr "Procesando IP"
-
-msgid "Ignored localhost address"
-msgstr "Ignorada IP de localhost"
-
-#, php-format
-msgid "Address located at \"%s\""
-msgstr "Dirección localizada en \"%s\""
-
-msgid "Finished processing all IPs"
-msgstr "Finalizado el procesado de todas las IPs"
-
msgid "Returns the long URL behind a short code"
msgstr "Devuelve la URL larga detrás de un código corto"
@@ -185,3 +197,19 @@ msgstr "URL larga:"
#, php-format
msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
+
+msgid "Processes visits where location is not set yet"
+msgstr "Procesa las visitas donde la localización no ha sido establecida aún"
+
+msgid "Processing IP"
+msgstr "Procesando IP"
+
+msgid "Ignored localhost address"
+msgstr "Ignorada IP de localhost"
+
+#, php-format
+msgid "Address located at \"%s\""
+msgstr "Dirección localizada en \"%s\""
+
+msgid "Finished processing all IPs"
+msgstr "Finalizado el procesado de todas las IPs"
diff --git a/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php b/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php
new file mode 100644
index 00000000..b224ad61
--- /dev/null
+++ b/module/CLI/src/Command/Shortcode/GeneratePreviewCommand.php
@@ -0,0 +1,89 @@
+shortUrlService = $shortUrlService;
+ $this->previewGenerator = $previewGenerator;
+ $this->translator = $translator;
+ parent::__construct(null);
+ }
+
+ public function configure()
+ {
+ $this->setName('shortcode:process-previews')
+ ->setDescription(
+ $this->translator->translate(
+ 'Processes and generates the previews for every URL, improving performance for later web requests.'
+ )
+ );
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output)
+ {
+ $page = 1;
+ do {
+ $shortUrls = $this->shortUrlService->listShortUrls($page);
+ $page += 1;
+
+ foreach ($shortUrls as $shortUrl) {
+ $this->processUrl($shortUrl->getOriginalUrl(), $output);
+ }
+ } while ($page <= $shortUrls->count());
+
+ $output->writeln('' . $this->translator->translate('Finished processing all URLs') . '');
+ }
+
+ protected function processUrl($url, OutputInterface $output)
+ {
+ try {
+ $output->write(sprintf($this->translator->translate('Processing URL %s...'), $url));
+ $this->previewGenerator->generatePreview($url);
+ $output->writeln($this->translator->translate(' Success!'));
+ } catch (PreviewGenerationException $e) {
+ $messages = [' ' . $this->translator->translate('Error') . ''];
+ if ($output->isVerbose()) {
+ $messages[] = '' . $e->__toString() . '';
+ }
+
+ $output->writeln($messages);
+ }
+ }
+}
diff --git a/module/CLI/test/Command/Shortcode/GeneratePreviewCommandTest.php b/module/CLI/test/Command/Shortcode/GeneratePreviewCommandTest.php
new file mode 100644
index 00000000..216c004a
--- /dev/null
+++ b/module/CLI/test/Command/Shortcode/GeneratePreviewCommandTest.php
@@ -0,0 +1,99 @@
+previewGenerator = $this->prophesize(PreviewGenerator::class);
+ $this->shortUrlService = $this->prophesize(ShortUrlService::class);
+
+ $command = new GeneratePreviewCommand(
+ $this->shortUrlService->reveal(),
+ $this->previewGenerator->reveal(),
+ Translator::factory([])
+ );
+ $app = new Application();
+ $app->add($command);
+
+ $this->commandTester = new CommandTester($command);
+ }
+
+ /**
+ * @test
+ */
+ public function previewsForEveryUrlAreGenerated()
+ {
+ $paginator = $this->createPaginator([
+ (new ShortUrl())->setOriginalUrl('http://foo.com'),
+ (new ShortUrl())->setOriginalUrl('https://bar.com'),
+ (new ShortUrl())->setOriginalUrl('http://baz.com/something'),
+ ]);
+ $this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
+
+ $this->previewGenerator->generatePreview('http://foo.com')->shouldBeCalledTimes(1);
+ $this->previewGenerator->generatePreview('https://bar.com')->shouldBeCalledTimes(1);
+ $this->previewGenerator->generatePreview('http://baz.com/something')->shouldBeCalledTimes(1);
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:process-previews'
+ ]);
+ }
+
+ /**
+ * @test
+ */
+ public function exceptionWillOutputError()
+ {
+ $items = [
+ (new ShortUrl())->setOriginalUrl('http://foo.com'),
+ (new ShortUrl())->setOriginalUrl('https://bar.com'),
+ (new ShortUrl())->setOriginalUrl('http://baz.com/something'),
+ ];
+ $paginator = $this->createPaginator($items);
+ $this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
+ $this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class)
+ ->shouldBeCalledTimes(count($items));
+
+ $this->commandTester->execute([
+ 'command' => 'shortcode:process-previews'
+ ]);
+ $output = $this->commandTester->getDisplay();
+ $this->assertEquals(count($items), substr_count($output, 'Error'));
+ }
+
+ protected function createPaginator(array $items)
+ {
+ $paginator = new Paginator(new ArrayAdapter($items));
+ $paginator->setItemCountPerPage(count($items));
+
+ return $paginator;
+ }
+}
diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php
index 4c5c6ace..c995b14d 100644
--- a/module/Common/config/dependencies.config.php
+++ b/module/Common/config/dependencies.config.php
@@ -4,30 +4,35 @@ use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
-use Shlinkio\Shlink\Common\Factory\CacheFactory;
-use Shlinkio\Shlink\Common\Factory\EntityManagerFactory;
-use Shlinkio\Shlink\Common\Factory\LoggerFactory;
-use Shlinkio\Shlink\Common\Factory\TranslatorFactory;
+use Shlinkio\Shlink\Common\Factory;
+use Shlinkio\Shlink\Common\Image;
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
-use Shlinkio\Shlink\Common\Service\IpLocationResolver;
+use Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Twig\Extension\TranslatorExtension;
+use Symfony\Component\Filesystem\Filesystem;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'dependencies' => [
+ 'invokables' => [
+ Filesystem::class => Filesystem::class,
+ ],
'factories' => [
- EntityManager::class => EntityManagerFactory::class,
+ EntityManager::class => Factory\EntityManagerFactory::class,
GuzzleHttp\Client::class => InvokableFactory::class,
- Cache::class => CacheFactory::class,
- 'Logger_Shlink' => LoggerFactory::class,
+ Cache::class => Factory\CacheFactory::class,
+ 'Logger_Shlink' => Factory\LoggerFactory::class,
- Translator::class => TranslatorFactory::class,
+ Translator::class => Factory\TranslatorFactory::class,
TranslatorExtension::class => AnnotatedFactory::class,
LocaleMiddleware::class => AnnotatedFactory::class,
- IpLocationResolver::class => AnnotatedFactory::class,
+ Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
+
+ Service\IpLocationResolver::class => AnnotatedFactory::class,
+ Service\PreviewGenerator::class => AnnotatedFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
diff --git a/module/Common/src/Exception/PreviewGenerationException.php b/module/Common/src/Exception/PreviewGenerationException.php
new file mode 100644
index 00000000..ac205495
--- /dev/null
+++ b/module/Common/src/Exception/PreviewGenerationException.php
@@ -0,0 +1,10 @@
+ [
+ Image::class => ImageFactory::class,
+ ]]);
+ }
+}
diff --git a/module/Common/src/Image/ImageBuilderInterface.php b/module/Common/src/Image/ImageBuilderInterface.php
new file mode 100644
index 00000000..a1479a81
--- /dev/null
+++ b/module/Common/src/Image/ImageBuilderInterface.php
@@ -0,0 +1,8 @@
+get('config')['phpwkhtmltopdf'];
+ $image = new Image(isset($config['images']) ? $config['images'] : null);
+
+ if (isset($options) && isset($options['url'])) {
+ $image->setPage($options['url']);
+ }
+
+ return $image;
+ }
+}
diff --git a/module/Common/src/Service/PreviewGenerator.php b/module/Common/src/Service/PreviewGenerator.php
new file mode 100644
index 00000000..03fa392f
--- /dev/null
+++ b/module/Common/src/Service/PreviewGenerator.php
@@ -0,0 +1,70 @@
+location = $location;
+ $this->imageBuilder = $imageBuilder;
+ $this->filesystem = $filesystem;
+ }
+
+ /**
+ * Generates and stores preview for provided website and returns the path to the image file
+ *
+ * @param string $url
+ * @return string
+ * @throws PreviewGenerationException
+ */
+ public function generatePreview($url)
+ {
+ /** @var Image $image */
+ $image = $this->imageBuilder->build(Image::class, ['url' => $url]);
+
+ // If the file already exists, return its path
+ $cacheId = sprintf('preview_%s.%s', urlencode($url), $image->type);
+ $path = $this->location . '/' . $cacheId;
+ if ($this->filesystem->exists($path)) {
+ return $path;
+ }
+
+ // Save and check if an error occurred
+ $image->saveAs($path);
+ $error = $image->getError();
+ if (! empty($error)) {
+ throw PreviewGenerationException::fromImageError($error);
+ }
+
+ // Cache the path and return it
+ return $path;
+ }
+}
diff --git a/module/Common/src/Service/PreviewGeneratorInterface.php b/module/Common/src/Service/PreviewGeneratorInterface.php
new file mode 100644
index 00000000..2e7ea0aa
--- /dev/null
+++ b/module/Common/src/Service/PreviewGeneratorInterface.php
@@ -0,0 +1,16 @@
+generateBinaryResponse($filePath, [
+ 'Content-Disposition' => 'attachment; filename=' . basename($filePath),
+ 'Content-Transfer-Encoding' => 'Binary',
+ 'Content-Description' => 'File Transfer',
+ 'Pragma' => 'public',
+ 'Expires' => '0',
+ 'Cache-Control' => 'must-revalidate',
+ ]);
+ }
+
+ protected function generateImageResponse($imagePath)
+ {
+ return $this->generateBinaryResponse($imagePath);
+ }
+
+ protected function generateBinaryResponse($path, $extraHeaders = [])
+ {
+ $body = new Stream($path);
+ return new Response($body, 200, ArrayUtils::merge([
+ 'Content-Type' => (new \finfo(FILEINFO_MIME))->file($path),
+ 'Content-Length' => (string) $body->getSize(),
+ ], $extraHeaders));
+ }
+}
diff --git a/module/Common/test/Image/ImageBuilderFactoryTest.php b/module/Common/test/Image/ImageBuilderFactoryTest.php
new file mode 100644
index 00000000..8e07b4a3
--- /dev/null
+++ b/module/Common/test/Image/ImageBuilderFactoryTest.php
@@ -0,0 +1,29 @@
+factory = new ImageBuilderFactory();
+ }
+
+ /**
+ * @test
+ */
+ public function serviceIsCreated()
+ {
+ $instance = $this->factory->__invoke(new ServiceManager(), '');
+ $this->assertInstanceOf(ImageBuilder::class, $instance);
+ }
+}
diff --git a/module/Common/test/Image/ImageFactoryTest.php b/module/Common/test/Image/ImageFactoryTest.php
new file mode 100644
index 00000000..c4a3bd6a
--- /dev/null
+++ b/module/Common/test/Image/ImageFactoryTest.php
@@ -0,0 +1,56 @@
+factory = new ImageFactory();
+ }
+
+ /**
+ * @test
+ */
+ public function noPageIsSetWhenOptionsAreNotProvided()
+ {
+ /** @var Image $image */
+ $image = $this->factory->__invoke(new ServiceManager(['services' => [
+ 'config' => ['phpwkhtmltopdf' => []],
+ ]]), '');
+ $this->assertInstanceOf(Image::class, $image);
+
+ $ref = new \ReflectionObject($image);
+ $page = $ref->getProperty('_page');
+ $page->setAccessible(true);
+ $this->assertNull($page->getValue($image));
+ }
+
+ /**
+ * @test
+ */
+ public function aPageIsSetWhenOptionsIncludeTheUrl()
+ {
+ $expectedPage = 'foo/bar.html';
+
+ /** @var Image $image */
+ $image = $this->factory->__invoke(new ServiceManager(['services' => [
+ 'config' => ['phpwkhtmltopdf' => []],
+ ]]), '', ['url' => $expectedPage]);
+ $this->assertInstanceOf(Image::class, $image);
+
+ $ref = new \ReflectionObject($image);
+ $page = $ref->getProperty('_page');
+ $page->setAccessible(true);
+ $this->assertEquals($expectedPage, $page->getValue($image));
+ }
+}
diff --git a/module/Common/test/Service/PreviewGeneratorTest.php b/module/Common/test/Service/PreviewGeneratorTest.php
new file mode 100644
index 00000000..60b5d024
--- /dev/null
+++ b/module/Common/test/Service/PreviewGeneratorTest.php
@@ -0,0 +1,89 @@
+image = $this->prophesize(Image::class);
+ $this->filesystem = $this->prophesize(Filesystem::class);
+
+ $this->generator = new PreviewGenerator(new ImageBuilder(new ServiceManager(), [
+ 'factories' => [
+ Image::class => function () {
+ return $this->image->reveal();
+ },
+ ]
+ ]), $this->filesystem->reveal(), 'dir');
+ }
+
+ /**
+ * @test
+ */
+ public function alreadyProcessedElementsAreNotProcessed()
+ {
+ $url = 'http://foo.com';
+ $this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(true)
+ ->shouldBeCalledTimes(1);
+ $this->image->saveAs(Argument::cetera())->shouldBeCalledTimes(0);
+ $this->assertEquals(sprintf('dir/preview_%s.png', urlencode($url)), $this->generator->generatePreview($url));
+ }
+
+ /**
+ * @test
+ */
+ public function nonProcessedElementsAreProcessed()
+ {
+ $url = 'http://foo.com';
+ $cacheId = sprintf('preview_%s.png', urlencode($url));
+ $expectedPath = 'dir/' . $cacheId;
+
+ $this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false)
+ ->shouldBeCalledTimes(1);
+
+ $this->image->saveAs($expectedPath)->shouldBeCalledTimes(1);
+ $this->image->getError()->willReturn('')->shouldBeCalledTimes(1);
+ $this->assertEquals($expectedPath, $this->generator->generatePreview($url));
+ }
+
+ /**
+ * @test
+ * @expectedException \Shlinkio\Shlink\Common\Exception\PreviewGenerationException
+ */
+ public function errorWhileGeneratingPreviewThrowsException()
+ {
+ $url = 'http://foo.com';
+ $cacheId = sprintf('preview_%s.png', urlencode($url));
+ $expectedPath = 'dir/' . $cacheId;
+
+ $this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false)
+ ->shouldBeCalledTimes(1);
+
+ $this->image->saveAs($expectedPath)->shouldBeCalledTimes(1);
+ $this->image->getError()->willReturn('Error!!')->shouldBeCalledTimes(1);
+
+ $this->generator->generatePreview($url);
+ }
+}
diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php
index 9f2c9cc9..f5c75bac 100644
--- a/module/Core/config/dependencies.config.php
+++ b/module/Core/config/dependencies.config.php
@@ -20,6 +20,7 @@ return [
// Middleware
Action\RedirectAction::class => AnnotatedFactory::class,
Action\QrCodeAction::class => AnnotatedFactory::class,
+ Action\PreviewAction::class => AnnotatedFactory::class,
Middleware\QrCodeCacheMiddleware::class => AnnotatedFactory::class,
],
],
diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php
index 9b0c071f..a3c70aeb 100644
--- a/module/Core/config/routes.config.php
+++ b/module/Core/config/routes.config.php
@@ -13,6 +13,23 @@ return [
],
[
'name' => 'short-url-qr-code',
+ 'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
+ 'middleware' => [
+ Middleware\QrCodeCacheMiddleware::class,
+ Action\QrCodeAction::class,
+ ],
+ 'allowed_methods' => ['GET'],
+ ],
+ [
+ 'name' => 'short-url-preview',
+ 'path' => '/{shortCode}/preview',
+ 'middleware' => Action\PreviewAction::class,
+ 'allowed_methods' => ['GET'],
+ ],
+
+ // Old QR code route. Deprecated
+ [
+ 'name' => 'short-url-qr-code-old',
'path' => '/qr/{shortCode}[/{size:[0-9]+}]',
'middleware' => [
Middleware\QrCodeCacheMiddleware::class,
diff --git a/module/Core/src/Action/PreviewAction.php b/module/Core/src/Action/PreviewAction.php
new file mode 100644
index 00000000..4c21501c
--- /dev/null
+++ b/module/Core/src/Action/PreviewAction.php
@@ -0,0 +1,85 @@
+previewGenerator = $previewGenerator;
+ $this->urlShortener = $urlShortener;
+ }
+
+ /**
+ * Process an incoming request and/or response.
+ *
+ * Accepts a server-side request and a response instance, and does
+ * something with them.
+ *
+ * If the response is not complete and/or further processing would not
+ * interfere with the work done in the middleware, or if the middleware
+ * wants to delegate to another process, it can use the `$out` callable
+ * if present.
+ *
+ * If the middleware does not return a value, execution of the current
+ * request is considered complete, and the response instance provided will
+ * be considered the response to return.
+ *
+ * Alternately, the middleware may return a response instance.
+ *
+ * Often, middleware will `return $out();`, with the assumption that a
+ * later middleware will return a response.
+ *
+ * @param Request $request
+ * @param Response $response
+ * @param null|callable $out
+ * @return null|Response
+ */
+ public function __invoke(Request $request, Response $response, callable $out = null)
+ {
+ $shortCode = $request->getAttribute('shortCode');
+
+ try {
+ $url = $this->urlShortener->shortCodeToUrl($shortCode);
+ if (! isset($url)) {
+ return $out($request, $response->withStatus(404), 'Not found');
+ }
+
+ $imagePath = $this->previewGenerator->generatePreview($url);
+ return $this->generateImageResponse($imagePath);
+ } catch (InvalidShortCodeException $e) {
+ return $out($request, $response->withStatus(404), 'Not found');
+ } catch (PreviewGenerationException $e) {
+ return $out($request, $response->withStatus(500), 'Preview generation error');
+ }
+ }
+}
diff --git a/module/Core/test/Action/PreviewActionTest.php b/module/Core/test/Action/PreviewActionTest.php
new file mode 100644
index 00000000..1ef49307
--- /dev/null
+++ b/module/Core/test/Action/PreviewActionTest.php
@@ -0,0 +1,114 @@
+previewGenerator = $this->prophesize(PreviewGenerator::class);
+ $this->urlShortener = $this->prophesize(UrlShortener::class);
+ $this->action = new PreviewAction($this->previewGenerator->reveal(), $this->urlShortener->reveal());
+ }
+
+ /**
+ * @test
+ */
+ public function invalidShortCodeFallbacksToNextMiddlewareWithStatusNotFound()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)->shouldBeCalledTimes(1);
+
+ $resp = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
+ new Response(),
+ function ($req, $resp) {
+ return $resp;
+ }
+ );
+
+ $this->assertEquals(404, $resp->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function correctShortCodeReturnsImageResponse()
+ {
+ $shortCode = 'abc123';
+ $url = 'foobar.com';
+ $path = __FILE__;
+ $this->urlShortener->shortCodeToUrl($shortCode)->willReturn($url)->shouldBeCalledTimes(1);
+ $this->previewGenerator->generatePreview($url)->willReturn($path)->shouldBeCalledTimes(1);
+
+ $resp = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
+ new Response()
+ );
+
+ $this->assertEquals(filesize($path), $resp->getHeaderLine('Content-length'));
+ $this->assertEquals((new \finfo(FILEINFO_MIME))->file($path), $resp->getHeaderLine('Content-type'));
+ }
+
+ /**
+ * @test
+ */
+ public function invalidShortcodeExceptionReturnsNotFound()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
+ ->shouldBeCalledTimes(1);
+
+ $resp = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
+ new Response(),
+ function ($req, $resp) {
+ return $resp;
+ }
+ );
+
+ $this->assertEquals(404, $resp->getStatusCode());
+ }
+
+ /**
+ * @test
+ */
+ public function previewExceptionReturnsNotFound()
+ {
+ $shortCode = 'abc123';
+ $this->urlShortener->shortCodeToUrl($shortCode)->willThrow(PreviewGenerationException::class)
+ ->shouldBeCalledTimes(1);
+
+ $resp = $this->action->__invoke(
+ ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
+ new Response(),
+ function ($req, $resp) {
+ return $resp;
+ }
+ );
+
+ $this->assertEquals(500, $resp->getStatusCode());
+ }
+}
diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo
index 915466d1..a8a16fdf 100644
Binary files a/module/Rest/lang/es.mo and b/module/Rest/lang/es.mo differ
diff --git a/module/Rest/lang/es.po b/module/Rest/lang/es.po
index c911722c..9da7489f 100644
--- a/module/Rest/lang/es.po
+++ b/module/Rest/lang/es.po
@@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
-"POT-Creation-Date: 2016-08-07 20:19+0200\n"
-"PO-Revision-Date: 2016-08-07 20:21+0200\n"
+"POT-Creation-Date: 2016-08-18 17:27+0200\n"
+"PO-Revision-Date: 2016-08-18 17:27+0200\n"
"Last-Translator: Alejandro Celaya \n"
"Language-Team: \n"
"Language: es_ES\n"
@@ -29,21 +29,19 @@ msgid "A URL was not provided"
msgstr "No se ha proporcionado una URL"
#, php-format
-msgid "Provided URL \"%s\" is invalid. Try with a different one."
-msgstr ""
-"La URL \"%s\" proporcionada es inválida. Inténtalo de nuevo con una "
-"diferente."
+msgid "Provided URL %s is invalid. Try with a different one."
+msgstr "La URL proporcionada \"%s\" es inválida. Prueba con una diferente."
msgid "Unexpected error occurred"
msgstr "Ocurrió un error inesperado"
#, php-format
-msgid "Provided short code \"%s\" is invalid"
-msgstr "El código corto \"%s\" proporcionado es inválido"
+msgid "Provided short code %s does not exist"
+msgstr "El código corto \"%s\" proporcionado no existe"
#, php-format
-msgid "No URL found for shortcode \"%s\""
-msgstr "No se ha encontrado una URL para el código corto \"%s\""
+msgid "No URL found for short code \"%s\""
+msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
#, php-format
msgid "Provided short code \"%s\" has an invalid format"
diff --git a/phpcs.xml b/phpcs.xml
index ae134872..469dd989 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -16,6 +16,7 @@
+ bin
module
config
public/index.php