Merge pull request #3 from acelaya/feature/7

Feature/7
This commit is contained in:
Alejandro Celaya 2016-08-18 17:30:57 +02:00 committed by GitHub
commit f77273ef93
29 changed files with 890 additions and 40 deletions

BIN
bin/wkhtmltoimage Executable file

Binary file not shown.

View file

@ -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",

View file

@ -0,0 +1,11 @@
<?php
return [
'phpwkhtmltopdf' => [
'images' => [
'binary' => 'bin/wkhtmltoimage',
'type' => 'jpg',
],
],
];

View file

@ -0,0 +1,8 @@
<?php
return [
'preview_generation' => [
'files_location' => 'data/cache',
],
];

View file

@ -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,

View file

@ -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,

Binary file not shown.

View file

@ -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 <alejandro@alejandrocelaya.com>\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 " <info>Success!</info>"
msgstr "<info>¡Correcto!</info>"
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"

View file

@ -0,0 +1,89 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class GeneratePreviewCommand extends Command
{
/**
* @var PreviewGeneratorInterface
*/
private $previewGenerator;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* @var ShortUrlServiceInterface
*/
private $shortUrlService;
/**
* GeneratePreviewCommand constructor.
* @param ShortUrlServiceInterface $shortUrlService
* @param PreviewGeneratorInterface $previewGenerator
* @param TranslatorInterface $translator
*
* @Inject({ShortUrlService::class, PreviewGenerator::class, "translator"})
*/
public function __construct(
ShortUrlServiceInterface $shortUrlService,
PreviewGeneratorInterface $previewGenerator,
TranslatorInterface $translator
) {
$this->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('<info>' . $this->translator->translate('Finished processing all URLs') . '</info>');
}
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(' <info>Success!</info>'));
} catch (PreviewGenerationException $e) {
$messages = [' <error>' . $this->translator->translate('Error') . '</error>'];
if ($output->isVerbose()) {
$messages[] = '<error>' . $e->__toString() . '</error>';
}
$output->writeln($messages);
}
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GeneratePreviewCommand;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
class GeneratePreviewCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
private $previewGenerator;
/**
* @var ObjectProphecy
*/
private $shortUrlService;
public function setUp()
{
$this->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;
}
}

View file

@ -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,

View file

@ -0,0 +1,10 @@
<?php
namespace Shlinkio\Shlink\Common\Exception;
class PreviewGenerationException extends RuntimeException
{
public static function fromImageError($error)
{
return new self(sprintf('Error generating a preview image with error: %s', $error));
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace Shlinkio\Shlink\Common\Image;
use mikehaertl\wkhtmlto\Image;
use Zend\ServiceManager\AbstractPluginManager;
class ImageBuilder extends AbstractPluginManager implements ImageBuilderInterface
{
protected $instanceOf = Image::class;
}

View file

@ -0,0 +1,31 @@
<?php
namespace Shlinkio\Shlink\Common\Image;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use mikehaertl\wkhtmlto\Image;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class ImageBuilderFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return new ImageBuilder($container, ['factories' => [
Image::class => ImageFactory::class,
]]);
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace Shlinkio\Shlink\Common\Image;
use Zend\ServiceManager\ServiceLocatorInterface;
interface ImageBuilderInterface extends ServiceLocatorInterface
{
}

View file

@ -0,0 +1,36 @@
<?php
namespace Shlinkio\Shlink\Common\Image;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use mikehaertl\wkhtmlto\Image;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class ImageFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['phpwkhtmltopdf'];
$image = new Image(isset($config['images']) ? $config['images'] : null);
if (isset($options) && isset($options['url'])) {
$image->setPage($options['url']);
}
return $image;
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace Shlinkio\Shlink\Common\Service;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use mikehaertl\wkhtmlto\Image;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Image\ImageBuilder;
use Shlinkio\Shlink\Common\Image\ImageBuilderInterface;
use Symfony\Component\Filesystem\Filesystem;
class PreviewGenerator implements PreviewGeneratorInterface
{
/**
* @var string
*/
private $location;
/**
* @var ImageBuilderInterface
*/
private $imageBuilder;
/**
* @var Filesystem
*/
private $filesystem;
/**
* PreviewGenerator constructor.
* @param ImageBuilderInterface $imageBuilder
* @param Filesystem $filesystem
* @param string $location
*
* @Inject({ImageBuilder::class, Filesystem::class, "config.preview_generation.files_location"})
*/
public function __construct(ImageBuilderInterface $imageBuilder, Filesystem $filesystem, $location)
{
$this->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;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
interface PreviewGeneratorInterface
{
/**
* 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);
}

View file

@ -0,0 +1,35 @@
<?php
namespace Shlinkio\Shlink\Common\Util;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
use Zend\Stdlib\ArrayUtils;
trait ResponseUtilsTrait
{
protected function generateDownloadFileResponse($filePath)
{
return $this->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));
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace ShlinkioTest\Shlink\Common\Image;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Image\ImageBuilder;
use Shlinkio\Shlink\Common\Image\ImageBuilderFactory;
use Zend\ServiceManager\ServiceManager;
class ImageBuilderFactoryTest extends TestCase
{
/**
* @var ImageBuilderFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new ImageBuilderFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke(new ServiceManager(), '');
$this->assertInstanceOf(ImageBuilder::class, $instance);
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace ShlinkioTest\Shlink\Common\Image;
use mikehaertl\wkhtmlto\Image;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Image\ImageFactory;
use Zend\ServiceManager\ServiceManager;
class ImageFactoryTest extends TestCase
{
/**
* @var ImageFactory
*/
protected $factory;
public function setUp()
{
$this->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));
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace ShlinkioTest\Shlink\Common\Service;
use mikehaertl\wkhtmlto\Image;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Image\ImageBuilder;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\ServiceManager;
class PreviewGeneratorTest extends TestCase
{
/**
* @var PreviewGenerator
*/
protected $generator;
/**
* @var ObjectProphecy
*/
protected $image;
/**
* @var ObjectProphecy
*/
protected $filesystem;
public function setUp()
{
$this->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);
}
}

View file

@ -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,
],
],

View file

@ -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,

View file

@ -0,0 +1,85 @@
<?php
namespace Shlinkio\Shlink\Core\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Common\Util\ResponseUtilsTrait;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Stratigility\MiddlewareInterface;
class PreviewAction implements MiddlewareInterface
{
use ResponseUtilsTrait;
/**
* @var PreviewGeneratorInterface
*/
private $previewGenerator;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* PreviewAction constructor.
* @param PreviewGeneratorInterface $previewGenerator
* @param UrlShortenerInterface $urlShortener
*
* @Inject({PreviewGenerator::class, UrlShortener::class})
*/
public function __construct(PreviewGeneratorInterface $previewGenerator, UrlShortenerInterface $urlShortener)
{
$this->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');
}
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace ShlinkioTest\Shlink\Core\Action;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Action\PreviewAction;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
class PreviewActionTest extends TestCase
{
/**
* @var PreviewAction
*/
protected $action;
/**
* @var ObjectProphecy
*/
private $previewGenerator;
/**
* @var ObjectProphecy
*/
private $urlShortener;
public function setUp()
{
$this->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());
}
}

Binary file not shown.

View file

@ -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 <alejandro@alejandrocelaya.com>\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"

View file

@ -16,6 +16,7 @@
</rule>
<!-- Paths to check -->
<file>bin</file>
<file>module</file>
<file>config</file>
<file>public/index.php</file>