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..a1e8061b 100644
--- a/composer.json
+++ b/composer.json
@@ -31,7 +31,8 @@
"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..6029b973
--- /dev/null
+++ b/config/autoload/phpwkhtmltopdf.global.php
@@ -0,0 +1,11 @@
+ [
+ 'images' => [
+ 'binary' => 'bin/wkhtmltoimage',
+ 'files_location' => 'data/cache/previews',
+ ],
+ ],
+
+];
diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php
index 4c5c6ace..6188d4ad 100644
--- a/module/Common/config/dependencies.config.php
+++ b/module/Common/config/dependencies.config.php
@@ -2,14 +2,16 @@
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
+use mikehaertl\wkhtmlto\Image;
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\Image\ImageFactory;
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 Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Factory\InvokableFactory;
@@ -22,12 +24,14 @@ return [
GuzzleHttp\Client::class => InvokableFactory::class,
Cache::class => CacheFactory::class,
'Logger_Shlink' => LoggerFactory::class,
+ Image::class => ImageFactory::class,
Translator::class => TranslatorFactory::class,
TranslatorExtension::class => AnnotatedFactory::class,
LocaleMiddleware::class => AnnotatedFactory::class,
- IpLocationResolver::class => AnnotatedFactory::class,
+ Service\IpLocationResolver::class => AnnotatedFactory::class,
+ Service\PreviewGenerator::class => AnnotatedFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
diff --git a/module/Common/src/Image/ImageFactory.php b/module/Common/src/Image/ImageFactory.php
new file mode 100644
index 00000000..af2ef768
--- /dev/null
+++ b/module/Common/src/Image/ImageFactory.php
@@ -0,0 +1,30 @@
+get('config')['phpwkhtmltopdf'];
+ return new Image(isset($config['images']) ? $config['images'] : null);
+ }
+}
diff --git a/module/Common/src/Service/PreviewGenerator.php b/module/Common/src/Service/PreviewGenerator.php
new file mode 100644
index 00000000..0a902967
--- /dev/null
+++ b/module/Common/src/Service/PreviewGenerator.php
@@ -0,0 +1,58 @@
+image = $image;
+ $this->cache = $cache;
+ $this->location = $location;
+ }
+
+ /**
+ * Generates and stores preview for provided website and returns the path to the image file
+ *
+ * @param string $url
+ * @return string
+ */
+ public function generatePreview($url)
+ {
+ $cacheId = sprintf('preview_%s.png', urlencode($url));
+ if ($this->cache->contains($cacheId)) {
+ return $this->cache->fetch($cacheId);
+ }
+
+ $path = $this->location . '/' . $cacheId;
+ $this->image->setPage($url);
+ $this->image->saveAs($path);
+ $this->cache->save($cacheId, $path);
+
+ return $path;
+ }
+}
diff --git a/module/Common/src/Service/PreviewGeneratorInterface.php b/module/Common/src/Service/PreviewGeneratorInterface.php
new file mode 100644
index 00000000..3029eb97
--- /dev/null
+++ b/module/Common/src/Service/PreviewGeneratorInterface.php
@@ -0,0 +1,13 @@
+image = $this->prophesize(Image::class);
+ $this->cache = new ArrayCache();
+ $this->generator = new PreviewGenerator($this->image->reveal(), $this->cache, 'dir');
+ }
+
+ /**
+ * @test
+ */
+ public function alreadyCachedElementsAreNotProcessed()
+ {
+ $url = 'http://foo.com';
+ $this->cache->save(sprintf('preview_%s.png', urlencode($url)), 'dir/foo.png');
+ $this->image->saveAs(Argument::cetera())->shouldBeCalledTimes(0);
+ $this->assertEquals('dir/foo.png', $this->generator->generatePreview($url));
+ }
+
+ /**
+ * @test
+ */
+ public function nonCachedElementsAreProcessedAndThenCached()
+ {
+ $url = 'http://foo.com';
+ $cacheId = sprintf('preview_%s.png', urlencode($url));
+ $expectedPath = 'dir/' . $cacheId;
+
+ $this->image->setPage($url)->shouldBeCalledTimes(1);
+ $this->image->saveAs($expectedPath)->shouldBeCalledTimes(1);
+
+ $this->assertFalse($this->cache->contains($cacheId));
+ $this->assertEquals($expectedPath, $this->generator->generatePreview($url));
+ $this->assertTrue($this->cache->contains($cacheId));
+ }
+}