diff --git a/.gitignore b/.gitignore index 9695be68..aebab397 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build composer.lock vendor/ .env +data/database.sqlite diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ca56a7..3da986a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## CHANGELOG +### 1.2.0 + +**Features** + +* [45: Allow to define tags on short codes, to improve filtering and classification](https://github.com/acelaya/url-shortener/issues/45) +* [7: Add website previews while listing available URLs](https://github.com/acelaya/url-shortener/issues/7) + +**Enhancements:** + +* [57: Add database migrations system to improve updating between versions](https://github.com/acelaya/url-shortener/issues/57) +* [31: Add support for other database management systems by improving the EntityManager factory](https://github.com/acelaya/url-shortener/issues/31) +* [51: Generate build process to paquetize the app and ease distribution](https://github.com/acelaya/url-shortener/issues/51) +* [38: Define installation script. It will request dynamic data on the fly so that there is no need to define env vars](https://github.com/acelaya/url-shortener/issues/38) + +**Tasks** + +* [55: Create update script which does not try to create a new database](https://github.com/acelaya/url-shortener/issues/55) +* [54: Add cache namespace to prevent name collisions with other apps in the same environment](https://github.com/acelaya/url-shortener/issues/54) +* [29: Use the acelaya/ze-content-based-error-handler package instead of custom error handler implementation](https://github.com/acelaya/url-shortener/issues/29) + +**Bugs** + +* [53: Fix entities database interoperability](https://github.com/acelaya/url-shortener/issues/53) +* [52: Add missing htaccess file for apache environments](https://github.com/acelaya/url-shortener/issues/52) + ### 1.1.0 **Features** diff --git a/bin/cli b/bin/cli index abbeed47..66086b23 100755 --- a/bin/cli +++ b/bin/cli @@ -2,16 +2,10 @@ get('translator'); -$translator->setLocale(env('CLI_LOCALE', 'en')); - -/** @var Application $app */ +/** @var CliApp $app */ $app = $container->get(CliApp::class); $app->run(); diff --git a/bin/install b/bin/install new file mode 100755 index 00000000..c47036a9 --- /dev/null +++ b/bin/install @@ -0,0 +1,14 @@ +#!/usr/bin/env php +add(new InstallCommand(new PhpArray())); +$app->setDefaultCommand('shlink:install'); +$app->run(); diff --git a/bin/update b/bin/update new file mode 100755 index 00000000..b226a9fa --- /dev/null +++ b/bin/update @@ -0,0 +1,14 @@ +#!/usr/bin/env php +add(new UpdateCommand(new PhpArray())); +$app->setDefaultCommand('shlink:install'); +$app->run(); 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/build.sh b/build.sh new file mode 100755 index 00000000..456c6d83 --- /dev/null +++ b/build.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -e + +if [ "$#" -ne 1 ]; then + echo "Usage:" >&2 + echo " $0 {version}" >&2 + exit 1 +fi + +version=$1 +builtcontent=$(readlink -f '../shlink_build_tmp') +projectdir=$(pwd) + +# Copy project content to temp dir +echo 'Copying project files...' +rm -rf "${builtcontent}" +mkdir "${builtcontent}" +cp -R "${projectdir}"/* "${builtcontent}" +cd "${builtcontent}" + +# Install dependencies +rm -r vendor +rm composer.lock +composer self-update +composer install --no-dev --optimize-autoloader + +# Delete development files +echo 'Deleting dev files...' +rm build.sh +rm CHANGELOG.md +rm composer.* +rm LICENSE +rm php* +rm README.md +rm -r build +rm -f data/database.sqlite +rm -rf data/{cache,log,proxies}/{*,.gitignore} +rm -rf config/params/{*,.gitignore} +rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore} + +# Compressing file +rm -f "${projectdir}"/build/shlink_${version}_dist.zip +zip -r "${projectdir}"/build/shlink_${version}_dist.zip . +rm -rf "${builtcontent}" diff --git a/composer.json b/composer.json index 1f9bf7bd..7f1ef082 100644 --- a/composer.json +++ b/composer.json @@ -23,13 +23,18 @@ "zendframework/zend-i18n": "^2.7", "mtymek/expressive-config-manager": "^0.4", "acelaya/zsm-annotated-services": "^0.2.0", + "acelaya/ze-content-based-error-handler": "^1.0", "doctrine/orm": "^2.5", "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", + "doctrine/migrations": "^1.4" }, "require-dev": { "phpunit/phpunit": "^5.0", diff --git a/config/autoload/app_options.global.php b/config/autoload/app_options.global.php index 4db642ce..e3f8fbdd 100644 --- a/config/autoload/app_options.global.php +++ b/config/autoload/app_options.global.php @@ -3,7 +3,7 @@ return [ 'app_options' => [ 'name' => 'Shlink', - 'version' => '1.1.0', + 'version' => '1.2.0', 'secret_key' => env('SECRET_KEY'), ], diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php index 209334b2..c0f4b585 100644 --- a/config/autoload/dependencies.global.php +++ b/config/autoload/dependencies.global.php @@ -1,5 +1,4 @@ [ Router\RouterInterface::class => Router\FastRouteRouter::class, - 'Zend\Expressive\FinalHandler' => ContentBasedErrorHandler::class, ], ], diff --git a/config/autoload/errorhandler.local.php.dist b/config/autoload/errorhandler.local.php.dist index 7e361e0a..40316fd9 100644 --- a/config/autoload/errorhandler.local.php.dist +++ b/config/autoload/errorhandler.local.php.dist @@ -1,5 +1,5 @@ [ + '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/config/config.php b/config/config.php index f5010bd0..5eec4734 100644 --- a/config/config.php +++ b/config/config.php @@ -1,10 +1,10 @@ getMergedConfig(); diff --git a/config/params/.gitignore b/config/params/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/config/params/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/data/migrations/Version20160819142757.php b/data/migrations/Version20160819142757.php new file mode 100644 index 00000000..40200c53 --- /dev/null +++ b/data/migrations/Version20160819142757.php @@ -0,0 +1,39 @@ +connection->getDatabasePlatform()->getName(); + $table = $schema->getTable('short_urls'); + $column = $table->getColumn('short_code'); + + if ($db === self::MYSQL) { + $column->setPlatformOption('collation', 'utf8_bin'); + } elseif ($db === self::SQLITE) { + $column->setPlatformOption('collate', 'BINARY'); + } + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + $db = $this->connection->getDatabasePlatform()->getName(); + } +} diff --git a/data/migrations/Version20160820191203.php b/data/migrations/Version20160820191203.php new file mode 100644 index 00000000..29e81040 --- /dev/null +++ b/data/migrations/Version20160820191203.php @@ -0,0 +1,80 @@ +getTables(); + foreach ($tables as $table) { + if ($table->getName() === 'tags') { + return; + } + } + + $this->createTagsTable($schema); + $this->createShortUrlsInTagsTable($schema); + } + + protected function createTagsTable(Schema $schema) + { + $table = $schema->createTable('tags'); + $table->addColumn('id', Type::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('name', Type::STRING, [ + 'length' => 255, + 'notnull' => true, + ]); + $table->addUniqueIndex(['name']); + + $table->setPrimaryKey(['id']); + } + + protected function createShortUrlsInTagsTable(Schema $schema) + { + $table = $schema->createTable('short_urls_in_tags'); + $table->addColumn('short_url_id', Type::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $table->addColumn('tag_id', Type::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->addForeignKeyConstraint('tags', ['tag_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->setPrimaryKey(['short_url_id', 'tag_id']); + } + + /** + * @param Schema $schema + */ + public function down(Schema $schema) + { + $schema->dropTable('short_urls_in_tags'); + $schema->dropTable('tags'); + } +} diff --git a/migrations.yml b/migrations.yml new file mode 100644 index 00000000..e732a0dc --- /dev/null +++ b/migrations.yml @@ -0,0 +1,4 @@ +name: ShlinkMigrations +migrations_namespace: ShlinkMigrations +table_name: migrations +migrations_directory: data/migrations diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 1244e259..8bd88607 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -4,11 +4,13 @@ use Shlinkio\Shlink\CLI\Command; return [ 'cli' => [ + 'locale' => env('CLI_LOCALE', 'en'), 'commands' => [ Command\Shortcode\GenerateShortcodeCommand::class, 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..87678715 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..b2ac0670 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-21 18:16+0200\n" +"PO-Revision-Date: 2016-08-21 18:16+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" @@ -76,6 +104,9 @@ msgstr "" msgid "The long URL to parse" msgstr "La URL larga a procesar" +msgid "Tags to apply to the new short URL" +msgstr "Etiquetas a aplicar a la nueva URL acortada" + msgid "A long URL was not provided. Which URL do you want to shorten?:" msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?" @@ -131,6 +162,9 @@ msgstr "Listar todas las URLs cortas" msgid "The first page to list (%s items per page)" msgstr "La primera página a listar (%s elementos por página)" +msgid "Whether to display the tags or not" +msgstr "Si se desea mostrar las etiquetas o no" + msgid "Short code" msgstr "Código corto" @@ -143,28 +177,15 @@ msgstr "Fecha de creación" msgid "Visits count" msgstr "Número de visitas" +msgid "Tags" +msgstr "Etiquetas" + msgid "You have reached last page" 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 +206,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/Install/InstallCommand.php b/module/CLI/src/Command/Install/InstallCommand.php new file mode 100644 index 00000000..1271e2ea --- /dev/null +++ b/module/CLI/src/Command/Install/InstallCommand.php @@ -0,0 +1,307 @@ + 'pdo_mysql', + 'PostgreSQL' => 'pdo_pgsql', + 'SQLite' => 'pdo_sqlite', + ]; + const SUPPORTED_LANGUAGES = ['en', 'es']; + + /** + * @var InputInterface + */ + private $input; + /** + * @var OutputInterface + */ + private $output; + /** + * @var QuestionHelper + */ + private $questionHelper; + /** + * @var ProcessHelper + */ + private $processHelper; + /** + * @var WriterInterface + */ + private $configWriter; + + /** + * InstallCommand constructor. + * @param WriterInterface $configWriter + * @param callable|null $databaseCreationLogic + */ + public function __construct(WriterInterface $configWriter) + { + parent::__construct(null); + $this->configWriter = $configWriter; + } + + public function configure() + { + $this->setName('shlink:install') + ->setDescription('Installs Shlink'); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $this->input = $input; + $this->output = $output; + $this->questionHelper = $this->getHelper('question'); + $this->processHelper = $this->getHelper('process'); + $params = []; + + $output->writeln([ + 'Welcome to Shlink!!', + 'This process will guide you through the installation.', + ]); + + // Check if a cached config file exists and drop it if so + if (file_exists('data/cache/app_config.php')) { + $output->write('Deleting old cached config...'); + if (unlink('data/cache/app_config.php')) { + $output->writeln(' Success'); + } else { + $output->writeln( + ' Failed! You will have to manually delete the data/cache/app_config.php file to get' + . ' new config applied.' + ); + } + } + + // Ask for custom config params + $params['DATABASE'] = $this->askDatabase(); + $params['URL_SHORTENER'] = $this->askUrlShortener(); + $params['LANGUAGE'] = $this->askLanguage(); + $params['APP'] = $this->askApplication(); + + // Generate config params files + $config = $this->buildAppConfig($params); + $this->configWriter->toFile('config/params/generated_config.php', $config, false); + $output->writeln(['Custom configuration properly generated!', '']); + + // Generate database + if (! $this->createDatabase()) { + return; + } + + // Run database migrations + $output->writeln('Updating database...'); + if (! $this->runCommand('php vendor/bin/doctrine-migrations migrations:migrate', 'Error updating database.')) { + return; + } + + // Generate proxies + $output->writeln('Generating proxies...'); + if (! $this->runCommand('php vendor/bin/doctrine.php orm:generate-proxies', 'Error generating proxies.')) { + return; + } + } + + protected function askDatabase() + { + $params = []; + $this->printTitle('DATABASE'); + + // Select database type + $databases = array_keys(self::DATABASE_DRIVERS); + $dbType = $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select database type (defaults to ' . $databases[0] . '):', + $databases, + 0 + )); + $params['DRIVER'] = self::DATABASE_DRIVERS[$dbType]; + + // Ask for connection params if database is not SQLite + if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) { + $params['NAME'] = $this->ask('Database name', 'shlink'); + $params['USER'] = $this->ask('Database username'); + $params['PASSWORD'] = $this->ask('Database password'); + } + + return $params; + } + + protected function askUrlShortener() + { + $this->printTitle('URL SHORTENER'); + + // Ask for URL shortener params + return [ + 'SCHEMA' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select schema for generated short URLs (defaults to http):', + ['http', 'https'], + 0 + )), + 'HOSTNAME' => $this->ask('Hostname for generated URLs'), + 'CHARS' => $this->ask( + 'Character set for generated short codes (leave empty to autogenerate one)', + null, + true + ) ?: str_shuffle(UrlShortener::DEFAULT_CHARS) + ]; + } + + protected function askLanguage() + { + $this->printTitle('LANGUAGE'); + + return [ + 'DEFAULT' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select default language for the application in general (defaults to ' + . self::SUPPORTED_LANGUAGES[0] . '):', + self::SUPPORTED_LANGUAGES, + 0 + )), + 'CLI' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( + 'Select default language for CLI executions (defaults to ' + . self::SUPPORTED_LANGUAGES[0] . '):', + self::SUPPORTED_LANGUAGES, + 0 + )), + ]; + } + + protected function askApplication() + { + $this->printTitle('APPLICATION'); + + return [ + 'SECRET' => $this->ask( + 'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)', + null, + true + ) ?: $this->generateRandomString(32), + ]; + } + + /** + * @param string $text + */ + protected function printTitle($text) + { + $text = trim($text); + $length = strlen($text) + 4; + $header = str_repeat('*', $length); + + $this->output->writeln([ + '', + '' . $header . '', + '* ' . strtoupper($text) . ' *', + '' . $header . '', + ]); + } + + /** + * @param string $text + * @param string|null $default + * @param bool $allowEmpty + * @return string + */ + protected function ask($text, $default = null, $allowEmpty = false) + { + if (isset($default)) { + $text .= ' (defaults to ' . $default . ')'; + } + do { + $value = $this->questionHelper->ask($this->input, $this->output, new Question( + '' . $text . ': ', + $default + )); + if (empty($value) && ! $allowEmpty) { + $this->output->writeln('Value can\'t be empty'); + } + } while (empty($value) && empty($default) && ! $allowEmpty); + + return $value; + } + + /** + * @param array $params + * @return array + */ + protected function buildAppConfig(array $params) + { + // Build simple config + $config = [ + 'app_options' => [ + 'secret_key' => $params['APP']['SECRET'], + ], + 'entity_manager' => [ + 'connection' => [ + 'driver' => $params['DATABASE']['DRIVER'], + ], + ], + 'translator' => [ + 'locale' => $params['LANGUAGE']['DEFAULT'], + ], + 'cli' => [ + 'locale' => $params['LANGUAGE']['CLI'], + ], + 'url_shortener' => [ + 'domain' => [ + 'schema' => $params['URL_SHORTENER']['SCHEMA'], + 'hostname' => $params['URL_SHORTENER']['HOSTNAME'], + ], + 'shortcode_chars' => $params['URL_SHORTENER']['CHARS'], + ], + ]; + + // Build dynamic database config + if ($params['DATABASE']['DRIVER'] === 'pdo_sqlite') { + $config['entity_manager']['connection']['path'] = 'data/database.sqlite'; + } else { + $config['entity_manager']['connection']['user'] = $params['DATABASE']['USER']; + $config['entity_manager']['connection']['password'] = $params['DATABASE']['PASSWORD']; + $config['entity_manager']['connection']['dbname'] = $params['DATABASE']['NAME']; + } + + return $config; + } + + protected function createDatabase() + { + $this->output->writeln('Initializing database...'); + return $this->runCommand('php vendor/bin/doctrine.php orm:schema-tool:create', 'Error generating database.'); + } + + /** + * @param string $command + * @param string $errorMessage + * @return bool + */ + protected function runCommand($command, $errorMessage) + { + $process = $this->processHelper->run($this->output, $command); + if ($process->isSuccessful()) { + $this->output->writeln(' Success!'); + return true; + } else { + if ($this->output->isVerbose()) { + return false; + } + $this->output->writeln( + ' ' . $errorMessage . ' Run this command with -vvv to see specific error info.' + ); + return false; + } + } +} diff --git a/module/CLI/src/Command/Install/UpdateCommand.php b/module/CLI/src/Command/Install/UpdateCommand.php new file mode 100644 index 00000000..3aed5512 --- /dev/null +++ b/module/CLI/src/Command/Install/UpdateCommand.php @@ -0,0 +1,12 @@ +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/src/Command/Shortcode/GenerateShortcodeCommand.php b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php index 2830fb40..eaa2d634 100644 --- a/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php +++ b/module/CLI/src/Command/Shortcode/GenerateShortcodeCommand.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; use Zend\Diactoros\Uri; @@ -54,7 +55,13 @@ class GenerateShortcodeCommand extends Command ->setDescription( $this->translator->translate('Generates a short code for provided URL and returns the short URL') ) - ->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse')); + ->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse')) + ->addOption( + 'tags', + 't', + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, + $this->translator->translate('Tags to apply to the new short URL') + ); } public function interact(InputInterface $input, OutputInterface $output) @@ -80,6 +87,13 @@ class GenerateShortcodeCommand extends Command public function execute(InputInterface $input, OutputInterface $output) { $longUrl = $input->getArgument('longUrl'); + $tags = $input->getOption('tags'); + $processedTags = []; + foreach ($tags as $key => $tag) { + $explodedTags = explode(',', $tag); + $processedTags = array_merge($processedTags, $explodedTags); + } + $tags = $processedTags; try { if (! isset($longUrl)) { @@ -87,10 +101,10 @@ class GenerateShortcodeCommand extends Command return; } - $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl), $tags); $shortUrl = (new Uri())->withPath($shortCode) - ->withScheme($this->domainConfig['schema']) - ->withHost($this->domainConfig['hostname']); + ->withScheme($this->domainConfig['schema']) + ->withHost($this->domainConfig['hostname']); $output->writeln([ sprintf('%s %s', $this->translator->translate('Processed URL:'), $longUrl), diff --git a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php index d57d3311..a3f77903 100644 --- a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php +++ b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php @@ -55,12 +55,20 @@ class ListShortcodesCommand extends Command PaginableRepositoryAdapter::ITEMS_PER_PAGE ), 1 + ) + ->addOption( + 'tags', + 't', + InputOption::VALUE_NONE, + $this->translator->translate('Whether to display the tags or not') ); } public function execute(InputInterface $input, OutputInterface $output) { $page = intval($input->getOption('page')); + $showTags = $input->getOption('tags'); + /** @var QuestionHelper $helper */ $helper = $this->getHelper('question'); @@ -68,15 +76,31 @@ class ListShortcodesCommand extends Command $result = $this->shortUrlService->listShortUrls($page); $page++; $table = new Table($output); - $table->setHeaders([ + + $headers = [ $this->translator->translate('Short code'), $this->translator->translate('Original URL'), $this->translator->translate('Date created'), $this->translator->translate('Visits count'), - ]); + ]; + if ($showTags) { + $headers[] = $this->translator->translate('Tags'); + } + $table->setHeaders($headers); foreach ($result as $row) { - $table->addRow(array_values($row->jsonSerialize())); + $shortUrl = $row->jsonSerialize(); + if ($showTags) { + $shortUrl['tags'] = []; + foreach ($row->getTags() as $tag) { + $shortUrl['tags'][] = $tag->getName(); + } + $shortUrl['tags'] = implode(', ', $shortUrl['tags']); + } else { + unset($shortUrl['tags']); + } + + $table->addRow(array_values($shortUrl)); } $table->render(); diff --git a/module/CLI/src/Factory/ApplicationFactory.php b/module/CLI/src/Factory/ApplicationFactory.php index a8e24bf3..d13cce7c 100644 --- a/module/CLI/src/Factory/ApplicationFactory.php +++ b/module/CLI/src/Factory/ApplicationFactory.php @@ -3,7 +3,9 @@ namespace Shlinkio\Shlink\CLI\Factory; use Interop\Container\ContainerInterface; use Interop\Container\Exception\ContainerException; +use Shlinkio\Shlink\Core\Options\AppOptions; use Symfony\Component\Console\Application as CliApp; +use Zend\I18n\Translator\Translator; use Zend\ServiceManager\Exception\ServiceNotCreatedException; use Zend\ServiceManager\Exception\ServiceNotFoundException; use Zend\ServiceManager\Factory\FactoryInterface; @@ -25,9 +27,12 @@ class ApplicationFactory implements FactoryInterface public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { $config = $container->get('config')['cli']; - $app = new CliApp('Shlink', '1.0.0'); + $appOptions = $container->get(AppOptions::class); + $translator = $container->get(Translator::class); + $translator->setLocale($config['locale']); $commands = isset($config['commands']) ? $config['commands'] : []; + $app = new CliApp($appOptions->getName(), $appOptions->getVersion()); foreach ($commands as $command) { if (! $container->has($command)) { continue; diff --git a/module/CLI/test/Command/Install/InstallCommandTest.php b/module/CLI/test/Command/Install/InstallCommandTest.php new file mode 100644 index 00000000..846a083b --- /dev/null +++ b/module/CLI/test/Command/Install/InstallCommandTest.php @@ -0,0 +1,104 @@ +prophesize(Process::class); + $processMock->isSuccessful()->willReturn(true); + $processHelper = $this->prophesize(ProcessHelper::class); + $processHelper->getName()->willReturn('process'); + $processHelper->setHelperSet(Argument::any())->willReturn(null); + $processHelper->run(Argument::cetera())->willReturn($processMock->reveal()); + + $app = new Application(); + $helperSet = $app->getHelperSet(); + $helperSet->set($processHelper->reveal()); + $app->setHelperSet($helperSet); + + $this->configWriter = $this->prophesize(WriterInterface::class); + $command = new InstallCommand($this->configWriter->reveal()); + $app->add($command); + + $questionHelper = $command->getHelper('question'); + $questionHelper->setInputStream($this->createInputStream()); + $this->commandTester = new CommandTester($command); + } + + protected function createInputStream() + { + $stream = fopen('php://memory', 'r+', false); + fputs($stream, <<configWriter->toFile(Argument::any(), [ + 'app_options' => [ + 'secret_key' => 'my_secret', + ], + 'entity_manager' => [ + 'connection' => [ + 'driver' => 'pdo_mysql', + 'dbname' => 'shlink_db', + 'user' => 'alejandro', + 'password' => '1234', + ], + ], + 'translator' => [ + 'locale' => 'en', + ], + 'cli' => [ + 'locale' => 'es', + ], + 'url_shortener' => [ + 'domain' => [ + 'schema' => 'http', + 'hostname' => 'doma.in', + ], + 'shortcode_chars' => 'abc123BCA', + ], + ], false)->shouldBeCalledTimes(1); + $this->commandTester->execute([ + 'command' => 'shlink:install', + ]); + } +} 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/CLI/test/Command/GenerateShortcodeCommandTest.php b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php similarity index 81% rename from module/CLI/test/Command/GenerateShortcodeCommandTest.php rename to module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php index 011dcb32..e2bf9c6e 100644 --- a/module/CLI/test/Command/GenerateShortcodeCommandTest.php +++ b/module/CLI/test/Command/Shortcode/GenerateShortcodeCommandTest.php @@ -1,5 +1,5 @@ urlShortener->urlToShortCode(Argument::any())->willReturn('abc123') - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::cetera())->willReturn('abc123') + ->shouldBeCalledTimes(1); $this->commandTester->execute([ 'command' => 'shortcode:generate', @@ -55,8 +55,8 @@ class GenerateShortcodeCommandTest extends TestCase */ public function exceptionWhileParsingLongUrlOutputsError() { - $this->urlShortener->urlToShortCode(Argument::any())->willThrow(new InvalidUrlException()) - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException()) + ->shouldBeCalledTimes(1); $this->commandTester->execute([ 'command' => 'shortcode:generate', diff --git a/module/CLI/test/Command/GetVisitsCommandTest.php b/module/CLI/test/Command/Shortcode/GetVisitsCommandTest.php similarity index 98% rename from module/CLI/test/Command/GetVisitsCommandTest.php rename to module/CLI/test/Command/Shortcode/GetVisitsCommandTest.php index 5657a91c..b50ad8af 100644 --- a/module/CLI/test/Command/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/Shortcode/GetVisitsCommandTest.php @@ -1,5 +1,5 @@ questionHelper->setInputStream($this->getInputStream('\n')); + $this->shortUrlService->listShortUrls(1)->willReturn(new Paginator(new ArrayAdapter())) + ->shouldBeCalledTimes(1); + + $this->commandTester->execute([ + 'command' => 'shortcode:list', + '--tags' => true, + ]); + $output = $this->commandTester->getDisplay(); + $this->assertTrue(strpos($output, 'Tags') > 0); + } + protected function getInputStream($inputData) { $stream = fopen('php://memory', 'r+', false); diff --git a/module/CLI/test/Command/ResolveUrlCommandTest.php b/module/CLI/test/Command/Shortcode/ResolveUrlCommandTest.php similarity index 98% rename from module/CLI/test/Command/ResolveUrlCommandTest.php rename to module/CLI/test/Command/Shortcode/ResolveUrlCommandTest.php index 06b5d350..06e69b49 100644 --- a/module/CLI/test/Command/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/Shortcode/ResolveUrlCommandTest.php @@ -1,5 +1,5 @@ [ 'config' => [ - 'cli' => $config, + 'cli' => array_merge($config, ['locale' => 'en']), ], + AppOptions::class => new AppOptions(), + Translator::class => Translator::factory([]), ]]); } } diff --git a/module/Common/config/dependencies.config.php b/module/Common/config/dependencies.config.php index b0cd571a..c995b14d 100644 --- a/module/Common/config/dependencies.config.php +++ b/module/Common/config/dependencies.config.php @@ -4,43 +4,44 @@ use Doctrine\Common\Cache\Cache; use Doctrine\ORM\EntityManager; use Monolog\Logger; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Common\ErrorHandler; -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, - LoggerInterface::class => LoggerFactory::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, - ErrorHandler\ContentBasedErrorHandler::class => AnnotatedFactory::class, - ErrorHandler\ErrorHandlerManager::class => ErrorHandler\ErrorHandlerManagerFactory::class, + Service\IpLocationResolver::class => AnnotatedFactory::class, + Service\PreviewGenerator::class => AnnotatedFactory::class, ], 'aliases' => [ 'em' => EntityManager::class, 'httpClient' => GuzzleHttp\Client::class, 'translator' => Translator::class, 'logger' => LoggerInterface::class, - Logger::class => LoggerInterface::class, AnnotatedFactory::CACHE_SERVICE => Cache::class, + Logger::class => 'Logger_Shlink', + LoggerInterface::class => 'Logger_Shlink', ], ], diff --git a/module/Common/config/error-handler.config.php b/module/Common/config/error-handler.config.php deleted file mode 100644 index d19b9ac6..00000000 --- a/module/Common/config/error-handler.config.php +++ /dev/null @@ -1,22 +0,0 @@ - [ - 'plugins' => [ - 'invokables' => [ - 'text/plain' => FinalHandler::class, - ], - 'factories' => [ - ContentBasedErrorHandler::DEFAULT_CONTENT => TemplatedErrorHandlerFactory::class, - ], - 'aliases' => [ - 'application/xhtml+xml' => ContentBasedErrorHandler::DEFAULT_CONTENT, - ], - ], - ], - -]; diff --git a/module/Common/src/ErrorHandler/ContentBasedErrorHandler.php b/module/Common/src/ErrorHandler/ContentBasedErrorHandler.php deleted file mode 100644 index be19e848..00000000 --- a/module/Common/src/ErrorHandler/ContentBasedErrorHandler.php +++ /dev/null @@ -1,76 +0,0 @@ -errorHandlerManager = $errorHandlerManager; - } - - /** - * Final handler for an application. - * - * @param Request $request - * @param Response $response - * @param null|mixed $err - * @return Response - */ - public function __invoke(Request $request, Response $response, $err = null) - { - // Try to get an error handler for provided request accepted type - $errorHandler = $this->resolveErrorHandlerFromAcceptHeader($request); - return $errorHandler($request, $response, $err); - } - - /** - * Tries to resolve - * - * @param Request $request - * @return callable - */ - protected function resolveErrorHandlerFromAcceptHeader(Request $request) - { - // Try to find an error handler for one of the accepted content types - $accepts = $request->hasHeader('Accept') ? $request->getHeaderLine('Accept') : self::DEFAULT_CONTENT; - $accepts = explode(',', $accepts); - foreach ($accepts as $accept) { - if (! $this->errorHandlerManager->has($accept)) { - continue; - } - - return $this->errorHandlerManager->get($accept); - } - - // If it wasn't possible to find an error handler for accepted content type, use default one if registered - if ($this->errorHandlerManager->has(self::DEFAULT_CONTENT)) { - return $this->errorHandlerManager->get(self::DEFAULT_CONTENT); - } - - // It wasn't possible to find an error handler - throw new InvalidArgumentException(sprintf( - 'It wasn\'t possible to find an error handler for ["%s"] content types. ' - . 'Make sure you have registered at least the default "%s" content type', - implode('", "', $accepts), - self::DEFAULT_CONTENT - )); - } -} diff --git a/module/Common/src/ErrorHandler/ErrorHandlerInterface.php b/module/Common/src/ErrorHandler/ErrorHandlerInterface.php deleted file mode 100644 index 9676c40a..00000000 --- a/module/Common/src/ErrorHandler/ErrorHandlerInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -get(AppOptions::class); + $adapter = $this->getAdapter($container); + $adapter->setNamespace($appOptions->__toString()); + + return $adapter; + } + + /** + * @param ContainerInterface $container + * @return Cache\CacheProvider + */ + protected function getAdapter(ContainerInterface $container) { // Try to get the adapter from config $config = $container->get('config'); @@ -47,7 +61,7 @@ class CacheFactory implements FactoryInterface /** * @param array $cacheConfig - * @return Cache\Cache + * @return Cache\CacheProvider */ protected function resolveCacheAdapter(array $cacheConfig) { diff --git a/module/Common/src/Image/ImageBuilder.php b/module/Common/src/Image/ImageBuilder.php new file mode 100644 index 00000000..0e4de841 --- /dev/null +++ b/module/Common/src/Image/ImageBuilder.php @@ -0,0 +1,10 @@ +get('config')['error_handler']; - $plugins = isset($config['plugins']) ? $config['plugins'] : []; - return new ErrorHandlerManager($container, $plugins); + return new ImageBuilder($container, ['factories' => [ + 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/ConfigProviderTest.php b/module/Common/test/ConfigProviderTest.php index dd7723df..864ab648 100644 --- a/module/Common/test/ConfigProviderTest.php +++ b/module/Common/test/ConfigProviderTest.php @@ -23,7 +23,6 @@ class ConfigProviderTest extends TestCase { $config = $this->configProvider->__invoke(); - $this->assertArrayHasKey('error_handler', $config); $this->assertArrayHasKey('middleware_pipeline', $config); $this->assertArrayHasKey('dependencies', $config); $this->assertArrayHasKey('twig', $config); diff --git a/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php b/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php deleted file mode 100644 index 6b480e54..00000000 --- a/module/Common/test/ErrorHandler/ContentBasedErrorHandlerTest.php +++ /dev/null @@ -1,75 +0,0 @@ -errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), [ - 'factories' => [ - 'text/html' => [$this, 'factory'], - 'application/json' => [$this, 'factory'], - ], - ])); - } - - public function factory($container, $name) - { - return function () use ($name) { - return $name; - }; - } - - /** - * @test - */ - public function correctAcceptHeaderValueInvokesErrorHandler() - { - $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,application/json'); - $result = $this->errorHandler->__invoke($request, new Response()); - $this->assertEquals('application/json', $result); - } - - /** - * @test - */ - public function defaultContentTypeIsUsedWhenNoAcceptHeaderisPresent() - { - $request = ServerRequestFactory::fromGlobals(); - $result = $this->errorHandler->__invoke($request, new Response()); - $this->assertEquals('text/html', $result); - } - - /** - * @test - */ - public function defaultContentTypeIsUsedWhenAcceptedContentIsNotSupported() - { - $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml'); - $result = $this->errorHandler->__invoke($request, new Response()); - $this->assertEquals('text/html', $result); - } - - /** - * @test - * @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException - */ - public function ifNoErrorHandlerIsFoundAnExceptionIsThrown() - { - $this->errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), [])); - $request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml'); - $result = $this->errorHandler->__invoke($request, new Response()); - } -} diff --git a/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php b/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php deleted file mode 100644 index be6d4e6d..00000000 --- a/module/Common/test/ErrorHandler/ErrorHandlerManagerFactoryTest.php +++ /dev/null @@ -1,35 +0,0 @@ -factory = new ErrorHandlerManagerFactory(); - } - - /** - * @test - */ - public function serviceIsCreated() - { - $instance = $this->factory->__invoke(new ServiceManager(['services' => [ - 'config' => [ - 'error_handler' => [ - 'plugins' => [], - ], - ], - ]]), ''); - $this->assertInstanceOf(ErrorHandlerManager::class, $instance); - } -} diff --git a/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php b/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php deleted file mode 100644 index 4b14f113..00000000 --- a/module/Common/test/ErrorHandler/ErrorHandlerManagerTest.php +++ /dev/null @@ -1,45 +0,0 @@ -pluginManager = new ErrorHandlerManager(new ServiceManager(), [ - 'services' => [ - 'foo' => function () { - }, - ], - 'invokables' => [ - 'invalid' => \stdClass::class, - ] - ]); - } - - /** - * @test - */ - public function callablesAreReturned() - { - $instance = $this->pluginManager->get('foo'); - $this->assertInstanceOf(\Closure::class, $instance); - } - - /** - * @test - * @expectedException \Zend\ServiceManager\Exception\InvalidServiceException - */ - public function nonCallablesThrowException() - { - $this->pluginManager->get('invalid'); - } -} diff --git a/module/Common/test/Factory/CacheFactoryTest.php b/module/Common/test/Factory/CacheFactoryTest.php index b7fc49a8..0d50aab6 100644 --- a/module/Common/test/Factory/CacheFactoryTest.php +++ b/module/Common/test/Factory/CacheFactoryTest.php @@ -8,6 +8,7 @@ use Doctrine\Common\Cache\MemcachedCache; use Doctrine\Common\Cache\RedisCache; use PHPUnit_Framework_TestCase as TestCase; use Shlinkio\Shlink\Common\Factory\CacheFactory; +use Shlinkio\Shlink\Core\Options\AppOptions; use Zend\ServiceManager\ServiceManager; class CacheFactoryTest extends TestCase @@ -109,6 +110,7 @@ class CacheFactoryTest extends TestCase 'options' => $options, ], ] : [], + AppOptions::class => new AppOptions(), ]]); } } 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 6a5596ee..f5c75bac 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -2,14 +2,14 @@ use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory; use Shlinkio\Shlink\Core\Action; use Shlinkio\Shlink\Core\Middleware; -use Shlinkio\Shlink\Core\Options\AppOptions; +use Shlinkio\Shlink\Core\Options; use Shlinkio\Shlink\Core\Service; return [ 'dependencies' => [ 'factories' => [ - AppOptions::class => AnnotatedFactory::class, + Options\AppOptions::class => Options\AppOptionsFactory::class, // Services Service\UrlShortener::class => AnnotatedFactory::class, @@ -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/lang/es.mo b/module/Core/lang/es.mo index d34bb83b..efc80eca 100644 Binary files a/module/Core/lang/es.mo and b/module/Core/lang/es.mo differ diff --git a/module/Core/lang/es.po b/module/Core/lang/es.po index 3393f072..3a3d4809 100644 --- a/module/Core/lang/es.po +++ b/module/Core/lang/es.po @@ -1,9 +1,9 @@ msgid "" msgstr "" "Project-Id-Version: Shlink 1.0\n" -"POT-Creation-Date: 2016-07-21 16:50+0200\n" -"PO-Revision-Date: 2016-07-21 16:51+0200\n" -"Last-Translator: \n" +"POT-Creation-Date: 2016-08-21 18:17+0200\n" +"PO-Revision-Date: 2016-08-21 18:17+0200\n" +"Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" "MIME-Version: 1.0\n" 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/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index f7b42895..cf1ed87e 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -28,8 +28,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable * type="string", * nullable=false, * length=10, - * unique=true, - * options={"collation": "utf8_bin"} + * unique=true * ) */ protected $shortCode; @@ -43,6 +42,16 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable * @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY") */ protected $visits; + /** + * @var Collection|Tag[] + * @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"}) + * @ORM\JoinTable(name="short_urls_in_tags", joinColumns={ + * @ORM\JoinColumn(name="short_url_id", referencedColumnName="id") + * }, inverseJoinColumns={ + * @ORM\JoinColumn(name="tag_id", referencedColumnName="id") + * }) + */ + protected $tags; /** * ShortUrl constructor. @@ -52,6 +61,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable $this->setDateCreated(new \DateTime()); $this->setVisits(new ArrayCollection()); $this->setShortCode(''); + $this->tags = new ArrayCollection(); } /** @@ -126,6 +136,34 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable return $this; } + /** + * @return Collection|Tag[] + */ + public function getTags() + { + return $this->tags; + } + + /** + * @param Collection|Tag[] $tags + * @return $this + */ + public function setTags($tags) + { + $this->tags = $tags; + return $this; + } + + /** + * @param Tag $tag + * @return $this + */ + public function addTag(Tag $tag) + { + $this->tags->add($tag); + return $this; + } + /** * Specify data which should be serialized to JSON * @link http://php.net/manual/en/jsonserializable.jsonserialize.php @@ -140,6 +178,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable 'originalUrl' => $this->originalUrl, 'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ISO8601) : null, 'visitsCount' => count($this->visits), + 'tags' => $this->tags->toArray(), ]; } } diff --git a/module/Core/src/Entity/Tag.php b/module/Core/src/Entity/Tag.php new file mode 100644 index 00000000..47123ff9 --- /dev/null +++ b/module/Core/src/Entity/Tag.php @@ -0,0 +1,52 @@ +name; + } + + /** + * @param string $name + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() + { + return $this->name; + } +} diff --git a/module/Core/src/Exception/InvalidShortCodeException.php b/module/Core/src/Exception/InvalidShortCodeException.php index b1e23d0b..85cc3d54 100644 --- a/module/Core/src/Exception/InvalidShortCodeException.php +++ b/module/Core/src/Exception/InvalidShortCodeException.php @@ -5,7 +5,7 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException; class InvalidShortCodeException extends RuntimeException { - public static function fromShortCode($shortCode, $charSet, \Exception $previous = null) + public static function fromCharset($shortCode, $charSet, \Exception $previous = null) { $code = isset($previous) ? $previous->getCode() : -1; return new static( @@ -14,4 +14,9 @@ class InvalidShortCodeException extends RuntimeException $previous ); } + + public static function fromNotFoundShortCode($shortCode) + { + return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode)); + } } diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php index 6ee1322c..cce854a2 100644 --- a/module/Core/src/Options/AppOptions.php +++ b/module/Core/src/Options/AppOptions.php @@ -1,7 +1,6 @@ has('config') ? $container->get('config') : []; + return new AppOptions(isset($config['app_options']) ? $config['app_options'] : []); + } +} diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 84552115..60845b88 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -5,11 +5,15 @@ use Acelaya\ZsmAnnotatedServices\Annotation\Inject; use Doctrine\ORM\EntityManagerInterface; use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Zend\Paginator\Paginator; class ShortUrlService implements ShortUrlServiceInterface { + use TagManagerTrait; + /** * @var EntityManagerInterface */ @@ -40,4 +44,26 @@ class ShortUrlService implements ShortUrlServiceInterface return $paginator; } + + /** + * @param string $shortCode + * @param string[] $tags + * @return ShortUrl + * @throws InvalidShortCodeException + */ + public function setTagsByShortCode($shortCode, array $tags = []) + { + /** @var ShortUrl $shortUrl */ + $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ + 'shortCode' => $shortCode, + ]); + if (! isset($shortUrl)) { + throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); + } + + $shortUrl->setTags($this->tagNamesToEntities($this->em, $tags)); + $this->em->flush(); + + return $shortUrl; + } } diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index c6885c39..5ad304ee 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Zend\Paginator\Paginator; interface ShortUrlServiceInterface @@ -11,4 +12,12 @@ interface ShortUrlServiceInterface * @return ShortUrl[]|Paginator */ public function listShortUrls($page = 1); + + /** + * @param string $shortCode + * @param string[] $tags + * @return ShortUrl + * @throws InvalidShortCodeException + */ + public function setTagsByShortCode($shortCode, array $tags = []); } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index ed87b604..bc422cab 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -12,9 +12,12 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; +use Shlinkio\Shlink\Core\Util\TagManagerTrait; class UrlShortener implements UrlShortenerInterface { + use TagManagerTrait; + const DEFAULT_CHARS = '123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ'; /** @@ -59,15 +62,16 @@ class UrlShortener implements UrlShortenerInterface * Creates and persists a unique shortcode generated for provided url * * @param UriInterface $url + * @param string[] $tags * @return string * @throws InvalidUrlException * @throws RuntimeException */ - public function urlToShortCode(UriInterface $url) + public function urlToShortCode(UriInterface $url, array $tags = []) { // If the url already exists in the database, just return its short code $shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([ - 'originalUrl' => $url + 'originalUrl' => $url, ]); if (isset($shortUrl)) { return $shortUrl->getShortCode(); @@ -88,7 +92,8 @@ class UrlShortener implements UrlShortenerInterface // Generate the short code and persist it $shortCode = $this->convertAutoincrementIdToShortCode($shortUrl->getId()); - $shortUrl->setShortCode($shortCode); + $shortUrl->setShortCode($shortCode) + ->setTags($this->tagNamesToEntities($this->em, $tags)); $this->em->flush(); $this->em->commit(); @@ -156,7 +161,7 @@ class UrlShortener implements UrlShortenerInterface // Validate short code format if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) { - throw InvalidShortCodeException::fromShortCode($shortCode, $this->chars); + throw InvalidShortCodeException::fromCharset($shortCode, $this->chars); } /** @var ShortUrl $shortUrl */ diff --git a/module/Core/src/Service/UrlShortenerInterface.php b/module/Core/src/Service/UrlShortenerInterface.php index 07d45401..4ddf4b7b 100644 --- a/module/Core/src/Service/UrlShortenerInterface.php +++ b/module/Core/src/Service/UrlShortenerInterface.php @@ -12,11 +12,12 @@ interface UrlShortenerInterface * Creates and persists a unique shortcode generated for provided url * * @param UriInterface $url + * @param string[] $tags * @return string * @throws InvalidUrlException * @throws RuntimeException */ - public function urlToShortCode(UriInterface $url); + public function urlToShortCode(UriInterface $url, array $tags = []); /** * Tries to find the mapped URL for provided short code. Returns null if not found diff --git a/module/Core/src/Util/TagManagerTrait.php b/module/Core/src/Util/TagManagerTrait.php new file mode 100644 index 00000000..9ca02b92 --- /dev/null +++ b/module/Core/src/Util/TagManagerTrait.php @@ -0,0 +1,38 @@ +normalizeTagName($tagName); + $tag = $em->getRepository(Tag::class)->findOneBy(['name' => $tagName]) ?: (new Tag())->setName($tagName); + $em->persist($tag); + $entities[] = $tag; + } + + return new Collections\ArrayCollection($entities); + } + + /** + * Tag names are trimmed, lowercased and spaces are replaced by dashes + * + * @param string $tagName + * @return string + */ + protected function normalizeTagName($tagName) + { + return str_replace(' ', '-', strtolower(trim($tagName))); + } +} 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/Core/test/Entity/TagTest.php b/module/Core/test/Entity/TagTest.php new file mode 100644 index 00000000..b41d7a36 --- /dev/null +++ b/module/Core/test/Entity/TagTest.php @@ -0,0 +1,18 @@ +setName('This is my name'); + $this->assertEquals($tag->getName(), $tag->jsonSerialize()); + } +} diff --git a/module/Core/test/Options/AppOptionsFactoryTest.php b/module/Core/test/Options/AppOptionsFactoryTest.php new file mode 100644 index 00000000..a3d9ac1a --- /dev/null +++ b/module/Core/test/Options/AppOptionsFactoryTest.php @@ -0,0 +1,29 @@ +factory = new AppOptionsFactory(); + } + + /** + * @test + */ + public function serviceIsCreated() + { + $instance = $this->factory->__invoke(new ServiceManager([]), ''); + $this->assertInstanceOf(AppOptions::class, $instance); + } +} diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index 1244d3a5..a87f91e1 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -2,10 +2,12 @@ namespace ShlinkioTest\Shlink\Core\Service; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; use PHPUnit_Framework_TestCase as TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Service\ShortUrlService; @@ -23,6 +25,8 @@ class ShortUrlServiceTest extends TestCase public function setUp() { $this->em = $this->prophesize(EntityManagerInterface::class); + $this->em->persist(Argument::any())->willReturn(null); + $this->em->flush()->willReturn(null); $this->service = new ShortUrlService($this->em->reveal()); } @@ -46,4 +50,40 @@ class ShortUrlServiceTest extends TestCase $list = $this->service->listShortUrls(); $this->assertEquals(4, $list->getCurrentItemCount()); } + + /** + * @test + * @expectedException \Shlinkio\Shlink\Core\Exception\InvalidShortCodeException + */ + public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode() + { + $shortCode = 'abc123'; + $repo = $this->prophesize(ShortUrlRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn(null) + ->shouldBeCalledTimes(1); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $this->service->setTagsByShortCode($shortCode); + } + + /** + * @test + */ + public function providedTagsAreGetFromRepoAndSetToTheShortUrl() + { + $shortUrl = $this->prophesize(ShortUrl::class); + $shortUrl->setTags(Argument::any())->shouldBeCalledTimes(1); + $shortCode = 'abc123'; + $repo = $this->prophesize(ShortUrlRepository::class); + $repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl->reveal()) + ->shouldBeCalledTimes(1); + $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); + + $tagRepo = $this->prophesize(EntityRepository::class); + $tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag())->shouldbeCalledTimes(1); + $tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldbeCalledTimes(1); + $this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal()); + + $this->service->setTagsByShortCode($shortCode, ['foo', 'bar']); + } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 685de2c3..a6b51cb7 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -18,7 +18,9 @@ return [ Action\ResolveUrlAction::class => AnnotatedFactory::class, Action\GetVisitsAction::class => AnnotatedFactory::class, Action\ListShortcodesAction::class => AnnotatedFactory::class, + Action\EditTagsAction::class => AnnotatedFactory::class, + Middleware\BodyParserMiddleware::class => AnnotatedFactory::class, Middleware\CrossDomainMiddleware::class => InvokableFactory::class, Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class, ], diff --git a/module/Rest/config/middleware-pipeline.config.php b/module/Rest/config/middleware-pipeline.config.php index 78c20c38..817fc568 100644 --- a/module/Rest/config/middleware-pipeline.config.php +++ b/module/Rest/config/middleware-pipeline.config.php @@ -7,6 +7,7 @@ return [ 'rest' => [ 'path' => '/rest', 'middleware' => [ + Middleware\BodyParserMiddleware::class, Middleware\CheckAuthenticationMiddleware::class, Middleware\CrossDomainMiddleware::class, ], diff --git a/module/Rest/config/rest.config.php b/module/Rest/config/rest.config.php deleted file mode 100644 index 223c864f..00000000 --- a/module/Rest/config/rest.config.php +++ /dev/null @@ -1,9 +0,0 @@ - [ - 'username' => env('REST_USER'), - 'password' => env('REST_PASSWORD'), - ], - -]; diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 4cc0f510..d27b9c6d 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -34,6 +34,12 @@ return [ 'middleware' => Action\GetVisitsAction::class, 'allowed_methods' => ['GET', 'OPTIONS'], ], + [ + 'name' => 'rest-edit-tags', + 'path' => '/rest/short-codes/{shortCode}/tags', + 'middleware' => Action\EditTagsAction::class, + 'allowed_methods' => ['PUT', 'OPTIONS'], + ], ], ]; diff --git a/module/Rest/lang/es.mo b/module/Rest/lang/es.mo index 915466d1..070394c0 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..b824cf72 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-21 18:17+0200\n" +"PO-Revision-Date: 2016-08-21 18:17+0200\n" "Last-Translator: Alejandro Celaya \n" "Language-Team: \n" "Language: es_ES\n" @@ -29,21 +29,22 @@ 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 "A list of tags was not provided" +msgstr "No se ha proporcionado una lista de etiquetas" #, 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 does not exist" +msgstr "El código corto \"%s\" proporcionado no existe" #, php-format msgid "Provided short code \"%s\" has an invalid format" diff --git a/module/Rest/src/Action/CreateShortcodeAction.php b/module/Rest/src/Action/CreateShortcodeAction.php index 5dd1221e..fdecedef 100644 --- a/module/Rest/src/Action/CreateShortcodeAction.php +++ b/module/Rest/src/Action/CreateShortcodeAction.php @@ -16,7 +16,7 @@ use Zend\I18n\Translator\TranslatorInterface; class CreateShortcodeAction extends AbstractRestAction { /** - * @var UrlShortener|UrlShortenerInterface + * @var UrlShortenerInterface */ private $urlShortener; /** @@ -31,7 +31,7 @@ class CreateShortcodeAction extends AbstractRestAction /** * GenerateShortcodeMiddleware constructor. * - * @param UrlShortenerInterface|UrlShortener $urlShortener + * @param UrlShortenerInterface $urlShortener * @param TranslatorInterface $translator * @param array $domainConfig * @param LoggerInterface|null $logger @@ -66,9 +66,10 @@ class CreateShortcodeAction extends AbstractRestAction ], 400); } $longUrl = $postData['longUrl']; + $tags = isset($postData['tags']) && is_array($postData['tags']) ? $postData['tags'] : []; try { - $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl)); + $shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl), $tags); $shortUrl = (new Uri())->withPath($shortCode) ->withScheme($this->domainConfig['schema']) ->withHost($this->domainConfig['hostname']); diff --git a/module/Rest/src/Action/EditTagsAction.php b/module/Rest/src/Action/EditTagsAction.php new file mode 100644 index 00000000..3dd76333 --- /dev/null +++ b/module/Rest/src/Action/EditTagsAction.php @@ -0,0 +1,73 @@ +shortUrlService = $shortUrlService; + $this->translator = $translator; + } + + /** + * @param Request $request + * @param Response $response + * @param callable|null $out + * @return null|Response + */ + protected function dispatch(Request $request, Response $response, callable $out = null) + { + $shortCode = $request->getAttribute('shortCode'); + $bodyParams = $request->getParsedBody(); + + if (! isset($bodyParams['tags'])) { + return new JsonResponse([ + 'error' => RestUtils::INVALID_ARGUMENT_ERROR, + 'message' => $this->translator->translate('A list of tags was not provided'), + ], 400); + } + $tags = $bodyParams['tags']; + + try { + $shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags); + return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]); + } catch (InvalidShortCodeException $e) { + return new JsonResponse([ + 'error' => RestUtils::getRestErrorCodeFromException($e), + 'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode), + ], 404); + } + } +} diff --git a/module/Rest/src/ErrorHandler/JsonErrorHandler.php b/module/Rest/src/ErrorHandler/JsonErrorHandler.php index 248bb6ae..79667c73 100644 --- a/module/Rest/src/ErrorHandler/JsonErrorHandler.php +++ b/module/Rest/src/ErrorHandler/JsonErrorHandler.php @@ -1,9 +1,9 @@ getMethod(); + if (! in_array($method, ['PUT', 'PATCH'])) { + return $out($request, $response); + } + + $contentType = $request->getHeaderLine('Content-type'); + $rawBody = (string) $request->getBody(); + if (in_array($contentType, ['application/json', 'text/json', 'application/x-json'])) { + return $out($request->withParsedBody(json_decode($rawBody, true)), $response); + } + + $parsedBody = []; + parse_str($rawBody, $parsedBody); + return $out($request->withParsedBody($parsedBody), $response); + } +} diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index 3019badf..4327df9e 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -48,7 +48,7 @@ class CrossDomainMiddleware implements MiddlewareInterface // Add OPTIONS-specific headers foreach ([ - 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS', // TODO Should be based on path + 'Access-Control-Allow-Methods' => 'GET,POST,PUT,DELETE,OPTIONS', // TODO Should be based on path 'Access-Control-Max-Age' => '1000', 'Access-Control-Allow-Headers' => $request->getHeaderLine('Access-Control-Request-Headers'), ] as $key => $value) { diff --git a/module/Rest/test/Action/CreateShortcodeActionTest.php b/module/Rest/test/Action/CreateShortcodeActionTest.php index dcc56bf6..c3b9e3ff 100644 --- a/module/Rest/test/Action/CreateShortcodeActionTest.php +++ b/module/Rest/test/Action/CreateShortcodeActionTest.php @@ -47,8 +47,9 @@ class CreateShortcodeActionTest extends TestCase */ public function properShortcodeConversionReturnsData() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willReturn('abc123') - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + ->willReturn('abc123') + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', @@ -63,8 +64,9 @@ class CreateShortcodeActionTest extends TestCase */ public function anInvalidUrlReturnsError() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(InvalidUrlException::class) - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + ->willThrow(InvalidUrlException::class) + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', @@ -79,8 +81,9 @@ class CreateShortcodeActionTest extends TestCase */ public function aGenericExceptionWillReturnError() { - $this->urlShortener->urlToShortCode(Argument::type(Uri::class))->willThrow(\Exception::class) - ->shouldBeCalledTimes(1); + $this->urlShortener->urlToShortCode(Argument::type(Uri::class), Argument::type('array')) + ->willThrow(\Exception::class) + ->shouldBeCalledTimes(1); $request = ServerRequestFactory::fromGlobals()->withParsedBody([ 'longUrl' => 'http://www.domain.com/foo/bar', diff --git a/module/Rest/test/Action/EditTagsActionTest.php b/module/Rest/test/Action/EditTagsActionTest.php new file mode 100644 index 00000000..b0813519 --- /dev/null +++ b/module/Rest/test/Action/EditTagsActionTest.php @@ -0,0 +1,76 @@ +shortUrlService = $this->prophesize(ShortUrlService::class); + $this->action = new EditTagsAction($this->shortUrlService->reveal(), Translator::factory([])); + } + + /** + * @test + */ + public function notProvidingTagsReturnsError() + { + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123'), + new Response() + ); + $this->assertEquals(400, $response->getStatusCode()); + } + + /** + * @test + */ + public function anInvalidShortCodeReturnsNotFound() + { + $shortCode = 'abc123'; + $this->shortUrlService->setTagsByShortCode($shortCode, [])->willThrow(InvalidShortCodeException::class) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123') + ->withParsedBody(['tags' => []]), + new Response() + ); + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * @test + */ + public function tagsListIsReturnedIfCorrectShortCodeIsProvided() + { + $shortCode = 'abc123'; + $this->shortUrlService->setTagsByShortCode($shortCode, [])->willReturn(new ShortUrl()) + ->shouldBeCalledTimes(1); + + $response = $this->action->__invoke( + ServerRequestFactory::fromGlobals()->withAttribute('shortCode', 'abc123') + ->withParsedBody(['tags' => []]), + new Response() + ); + $this->assertEquals(200, $response->getStatusCode()); + } +} diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 6801a82b..0270183f 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -25,7 +25,6 @@ class ConfigProviderTest extends TestCase $this->assertArrayHasKey('error_handler', $config); $this->assertArrayHasKey('middleware_pipeline', $config); - $this->assertArrayHasKey('rest', $config); $this->assertArrayHasKey('routes', $config); $this->assertArrayHasKey('dependencies', $config); $this->assertArrayHasKey('translator', $config); diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php new file mode 100644 index 00000000..17c3057b --- /dev/null +++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php @@ -0,0 +1,79 @@ +middleware = new BodyParserMiddleware(); + } + + /** + * @test + */ + public function requestsFromOtherMethodsJustFallbackToNextMiddleware() + { + $request = ServerRequestFactory::fromGlobals()->withMethod('GET'); + $test = $this; + $this->middleware->__invoke($request, new Response(), function ($req, $resp) use ($test, $request) { + $test->assertSame($request, $req); + }); + + $request = $request->withMethod('POST'); + $test = $this; + $this->middleware->__invoke($request, new Response(), function ($req, $resp) use ($test, $request) { + $test->assertSame($request, $req); + }); + } + + /** + * @test + */ + public function jsonRequestsAreJsonDecoded() + { + $body = new Stream('php://temp', 'wr'); + $body->write('{"foo": "bar", "bar": ["one", 5]}'); + $request = ServerRequestFactory::fromGlobals()->withMethod('PUT') + ->withBody($body) + ->withHeader('content-type', 'application/json'); + $test = $this; + $this->middleware->__invoke($request, new Response(), function (Request $req, $resp) use ($test, $request) { + $test->assertNotSame($request, $req); + $test->assertEquals([ + 'foo' => 'bar', + 'bar' => ['one', 5], + ], $req->getParsedBody()); + }); + } + + /** + * @test + */ + public function regularRequestsAreUrlDecoded() + { + $body = new Stream('php://temp', 'wr'); + $body->write('foo=bar&bar[]=one&bar[]=5'); + $request = ServerRequestFactory::fromGlobals()->withMethod('PUT') + ->withBody($body); + $test = $this; + $this->middleware->__invoke($request, new Response(), function (Request $req, $resp) use ($test, $request) { + $test->assertNotSame($request, $req); + $test->assertEquals([ + 'foo' => 'bar', + 'bar' => ['one', 5], + ], $req->getParsedBody()); + }); + } +} diff --git a/phpcs.xml b/phpcs.xml index ae134872..bd3ef63a 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -16,7 +16,10 @@ + bin module + data/migrations config public/index.php + config/params/* diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 00000000..a5c40815 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,17 @@ +RewriteEngine On +# The following rule tells Apache that if the requested filename +# exists, simply serve it. +RewriteCond %{REQUEST_FILENAME} -s [OR] +RewriteCond %{REQUEST_FILENAME} -l [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^.*$ - [NC,L] + +# The following rewrites all other queries to index.php. The +# condition ensures that if you are using Apache aliases to do +# mass virtual hosting, the base path will be prepended to +# allow proper resolution of the index.php file; it will work +# in non-aliased environments as well, providing a safe, one-size +# fits all solution. +RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$ +RewriteRule ^(.*) - [E=BASE:%1] +RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L]