Merge branch 'develop'

This commit is contained in:
Alejandro Celaya 2016-08-01 21:30:49 +02:00
commit 9ab4b9ab43
161 changed files with 4778 additions and 677 deletions

View file

@ -4,6 +4,10 @@ SHORTENED_URL_SCHEMA=
SHORTENED_URL_HOSTNAME=
SHORTCODE_CHARS=
# Language
DEFAULT_LOCALE=
CLI_LOCALE=
# Database
DB_USER=
DB_PASSWORD=

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.idea
build
composer.lock
vendor/

View file

@ -8,7 +8,7 @@ branches:
php:
- 5.6
- 7
- hhvm
- 7.1
before_script:
- composer self-update

View file

@ -1,5 +1,32 @@
## CHANGELOG
### 1.0.0
**Enhancements:**
* [33: Create a command to generate a short code charset by randomizing the default one](https://github.com/acelaya/url-shortener/issues/33)
* [15: Return JSON/HTML responses for errors (4xx and 5xx) based on accept header (content negotiation)](https://github.com/acelaya/url-shortener/issues/15)
* [23: Translate application literals](https://github.com/acelaya/url-shortener/issues/23)
* [21: Allow to filter visits by date range](https://github.com/acelaya/url-shortener/issues/21)
* [22: Save visits locations data on a visit_locations table](https://github.com/acelaya/url-shortener/issues/22)
* [20: Inject cross domain headers in response only if the Origin header is present in the request](https://github.com/acelaya/url-shortener/issues/20)
* [11: Separate code into multiple modules](https://github.com/acelaya/url-shortener/issues/11)
* [18: Group routable middleware in an Action namespace](https://github.com/acelaya/url-shortener/issues/18)
**Tasks**
* [36: Remove hhvm from the CI matrix since it doesn't support array constants and will fail](https://github.com/acelaya/url-shortener/issues/36)
* [4: Installation steps](https://github.com/acelaya/url-shortener/issues/4)
* [6: Remove dependency on expressive helpers package](https://github.com/acelaya/url-shortener/issues/6)
* [30: Replace the "services" first level config entry by "dependencies", in order to fulfill default Expressive name](https://github.com/acelaya/url-shortener/issues/30)
* [12: Improve code coverage](https://github.com/acelaya/url-shortener/issues/12)
* [25: Replace "Middleware" suffix on routable middlewares by "Action"](https://github.com/acelaya/url-shortener/issues/25)
* [19: Update the vendor and app namespace from Acelaya\UrlShortener to Shlinkio\Shlink](https://github.com/acelaya/url-shortener/issues/19)
**Bugs**
* [24: Prevent duplicated shortcodes errors because of the case insensitive behavior on MySQL](https://github.com/acelaya/url-shortener/issues/24)
### 0.2.0
**Enhancements:**

View file

@ -1,2 +1,9 @@
# url-shortener
# Shlink
[![Build Status](https://travis-ci.org/shlinkio/shlink.svg?branch=master)](https://travis-ci.org/shlinkio/shlink)
[![Code Coverage](https://scrutinizer-ci.com/g/shlinkio/shlink/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/shlinkio/shlink/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
[![Latest Stable Version](https://poser.pugx.org/shlinkio/shlink/v/stable.png)](https://packagist.org/packages/shlinkio/shlink)
[![License](https://poser.pugx.org/shlinkio/shlink/license.png)](https://packagist.org/packages/shlinkio/shlink)
A PHP-based URL shortener application with analytics and management

View file

@ -2,9 +2,16 @@
<?php
use Interop\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
use Symfony\Component\Console\Application;
use Zend\I18n\Translator\Translator;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
/** @var Translator $translator */
$translator = $container->get('translator');
$translator->setLocale(env('CLI_LOCALE', 'en'));
/** @var Application $app */
$app = $container->get(CliApp::class);
$app->run();

View file

@ -1,11 +1,12 @@
{
"name": "acelaya/url-shortener",
"name": "shlinkio/shlink",
"type": "project",
"homepage": "https://github.com/acelaya/url-shortener",
"homepage": "http://shlink.io",
"description": "A self-hosted and PHP-based URL shortener application with CLI and REST interfaces",
"license": "MIT",
"authors": [
{
"name": "Alejandro Celaya ALastrué",
"name": "Alejandro Celaya Alastrué",
"homepage": "http://www.alejandrocelaya.com",
"email": "alejandro@alejandrocelaya.com"
}
@ -13,15 +14,17 @@
"require": {
"php": "^5.6 || ^7.0",
"zendframework/zend-expressive": "^1.0",
"zendframework/zend-expressive-helpers": "^2.0",
"zendframework/zend-expressive-fastroute": "^1.1",
"zendframework/zend-expressive-twigrenderer": "^1.0",
"zendframework/zend-stdlib": "^2.7",
"zendframework/zend-servicemanager": "^3.0",
"zendframework/zend-paginator": "^2.6",
"zendframework/zend-config": "^2.6",
"zendframework/zend-i18n": "^2.7",
"mtymek/expressive-config-manager": "^0.4",
"acelaya/zsm-annotated-services": "^0.2.0",
"doctrine/orm": "^2.5",
"guzzlehttp/guzzle": "^6.2",
"acelaya/zsm-annotated-services": "^0.2.0",
"symfony/console": "^3.0"
},
"require-dev": {
@ -34,12 +37,21 @@
},
"autoload": {
"psr-4": {
"Acelaya\\UrlShortener\\": "src"
}
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
"Shlinkio\\Shlink\\Common\\": "module/Common/src"
},
"files": [
"module/Common/functions/functions.php"
]
},
"autoload-dev": {
"psr-4": {
"AcelayaTest\\UrlShortener\\": "tests"
"ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test",
"ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test",
"ShlinkioTest\\Shlink\\Core\\": "module/Core/test",
"ShlinkioTest\\Shlink\\Common\\": "module/Common/test"
}
},
"scripts": {

View file

@ -3,9 +3,9 @@ return [
'database' => [
'driver' => 'pdo_mysql',
'user' => getenv('DB_USER'),
'password' => getenv('DB_PASSWORD'),
'dbname' => getenv('DB_NAME') ?: 'acelaya_url_shortener',
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'dbname' => env('DB_NAME', 'shlink'),
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'

View file

@ -0,0 +1,24 @@
<?php
use Shlinkio\Shlink\Common\ErrorHandler\ContentBasedErrorHandler;
use Zend\Expressive;
use Zend\Expressive\Container;
use Zend\Expressive\Router;
use Zend\Expressive\Template;
use Zend\Expressive\Twig;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'dependencies' => [
'factories' => [
Expressive\Application::class => Container\ApplicationFactory::class,
Router\FastRouteRouter::class => InvokableFactory::class,
Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class,
],
'aliases' => [
Router\RouterInterface::class => Router\FastRouteRouter::class,
'Zend\Expressive\FinalHandler' => ContentBasedErrorHandler::class,
],
],
];

View file

@ -1,14 +1,13 @@
<?php
use Shlinkio\Shlink\Common\ErrorHandler\ContentBasedErrorHandler;
use Zend\Expressive\Container\WhoopsErrorHandlerFactory;
return [
'services' => [
'dependencies' => [
'invokables' => [
'Zend\Expressive\Whoops' => Whoops\Run::class,
'Zend\Expressive\WhoopsPageHandler' => Whoops\Handler\PrettyPageHandler::class,
],
'factories' => [
'Zend\Expressive\FinalHandler' => Zend\Expressive\Container\WhoopsErrorHandlerFactory::class,
],
],
'whoops' => [
@ -18,4 +17,12 @@ return [
'ajax_only' => true,
],
],
'error_handler' => [
'plugins' => [
'factories' => [
ContentBasedErrorHandler::DEFAULT_CONTENT => WhoopsErrorHandlerFactory::class,
],
],
],
];

View file

@ -1,18 +1,9 @@
<?php
use Acelaya\UrlShortener\Middleware;
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\Helper;
return [
'middleware_pipeline' => [
'always' => [
'middleware' => [
Helper\ServerUrlMiddleware::class,
],
'priority' => 10000,
],
'routing' => [
'middleware' => [
ApplicationFactory::ROUTING_MIDDLEWARE,
@ -20,27 +11,11 @@ return [
'priority' => 10,
],
'rest' => [
'path' => '/rest',
'middleware' => [
Middleware\CheckAuthenticationMiddleware::class,
Middleware\CrossDomainMiddleware::class,
],
'priority' => 5,
],
'post-routing' => [
'middleware' => [
Helper\UrlHelperMiddleware::class,
ApplicationFactory::DISPATCH_MIDDLEWARE,
],
'priority' => 1,
],
'error' => [
'middleware' => [],
'error' => true,
'priority' => -10000,
],
],
];

View file

@ -1,9 +0,0 @@
<?php
return [
'rest' => [
'username' => getenv('REST_USER'),
'password' => getenv('REST_PASSWORD'),
],
];

View file

@ -1,70 +0,0 @@
<?php
use Acelaya\UrlShortener\CLI;
use Acelaya\UrlShortener\Factory\CacheFactory;
use Acelaya\UrlShortener\Factory\EntityManagerFactory;
use Acelaya\UrlShortener\Middleware;
use Acelaya\UrlShortener\Service;
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Console;
use Zend\Expressive;
use Zend\Expressive\Container;
use Zend\Expressive\Helper;
use Zend\Expressive\Router;
use Zend\Expressive\Template;
use Zend\Expressive\Twig;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'services' => [
'factories' => [
Expressive\Application::class => Container\ApplicationFactory::class,
Console\Application::class => CLI\Factory\ApplicationFactory::class,
// Url helpers
Helper\UrlHelper::class => Helper\UrlHelperFactory::class,
Helper\ServerUrlMiddleware::class => Helper\ServerUrlMiddlewareFactory::class,
Helper\UrlHelperMiddleware::class => Helper\UrlHelperMiddlewareFactory::class,
Helper\ServerUrlHelper::class => InvokableFactory::class,
Router\FastRouteRouter::class => InvokableFactory::class,
// View
'Zend\Expressive\FinalHandler' => Container\TemplatedErrorHandlerFactory::class,
Template\TemplateRendererInterface::class => Twig\TwigRendererFactory::class,
// Services
EntityManager::class => EntityManagerFactory::class,
GuzzleHttp\Client::class => InvokableFactory::class,
Service\UrlShortener::class => AnnotatedFactory::class,
Service\VisitsTracker::class => AnnotatedFactory::class,
Service\ShortUrlService::class => AnnotatedFactory::class,
Service\RestTokenService::class => AnnotatedFactory::class,
Cache::class => CacheFactory::class,
// Cli commands
CLI\Command\GenerateShortcodeCommand::class => AnnotatedFactory::class,
CLI\Command\ResolveUrlCommand::class => AnnotatedFactory::class,
CLI\Command\ListShortcodesCommand::class => AnnotatedFactory::class,
CLI\Command\GetVisitsCommand::class => AnnotatedFactory::class,
// Middleware
Middleware\Routable\RedirectMiddleware::class => AnnotatedFactory::class,
Middleware\Rest\AuthenticateMiddleware::class => AnnotatedFactory::class,
Middleware\Rest\CreateShortcodeMiddleware::class => AnnotatedFactory::class,
Middleware\Rest\ResolveUrlMiddleware::class => AnnotatedFactory::class,
Middleware\Rest\GetVisitsMiddleware::class => AnnotatedFactory::class,
Middleware\Rest\ListShortcodesMiddleware::class => AnnotatedFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleHttp\Client::class,
Router\RouterInterface::class => Router\FastRouteRouter::class,
AnnotatedFactory::CACHE_SERVICE => Cache::class,
]
],
];

View file

@ -2,12 +2,6 @@
return [
'templates' => [
'paths' => [
'templates'
],
],
'twig' => [
'cache_dir' => 'data/cache/twig',
'extensions' => [

View file

@ -0,0 +1,8 @@
<?php
return [
'translator' => [
'locale' => env('DEFAULT_LOCALE', 'en'),
],
];

View file

@ -1,12 +1,14 @@
<?php
use Shlinkio\Shlink\Core\Service\UrlShortener;
return [
'url_shortener' => [
'domain' => [
'schema' => getenv('SHORTENED_URL_SCHEMA') ?: 'http',
'hostname' => getenv('SHORTENED_URL_HOSTNAME'),
'schema' => env('SHORTENED_URL_SCHEMA', 'http'),
'hostname' => env('SHORTENED_URL_HOSTNAME'),
],
'shortcode_chars' => getenv('SHORTCODE_CHARS'),
'shortcode_chars' => env('SHORTCODE_CHARS', UrlShortener::DEFAULT_CHARS),
],
];

View file

@ -1,14 +1,8 @@
<?php
return [
'debug' => false,
'config_cache_enabled' => true,
'config_cache_enabled' => false,
'zend-expressive' => [
'error_handler' => [
'template_404' => 'error/404.html.twig',
'template_error' => 'error/error.html.twig',
],
],
];

View file

@ -4,7 +4,7 @@ use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Interop\Container\ContainerInterface;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/config/container.php';
$container = include __DIR__ . '/container.php';
/** @var EntityManager $em */
$em = $container->get(EntityManager::class);

View file

@ -1,6 +1,10 @@
<?php
use Zend\Stdlib\ArrayUtils;
use Zend\Stdlib\Glob;
use Shlinkio\Shlink\CLI;
use Shlinkio\Shlink\Common;
use Shlinkio\Shlink\Core;
use Shlinkio\Shlink\Rest;
use Zend\Expressive\ConfigManager\ConfigManager;
use Zend\Expressive\ConfigManager\ZendConfigProvider;
/**
* Configuration files are loaded in a specific order. First ``global.php``, then ``*.global.php``.
@ -11,22 +15,10 @@ use Zend\Stdlib\Glob;
* Obviously, if you use closures in your config you can't cache it.
*/
$cachedConfigFile = 'data/cache/app_config.php';
$config = [];
if (is_file($cachedConfigFile)) {
// Try to load the cached config
$config = include $cachedConfigFile;
} else {
// Load configuration from autoload path
foreach (Glob::glob('config/autoload/{{,*.}global,{,*.}local}.php', Glob::GLOB_BRACE) as $file) {
$config = ArrayUtils::merge($config, include $file);
}
// Cache config if enabled
if (isset($config['config_cache_enabled']) && $config['config_cache_enabled'] === true) {
file_put_contents($cachedConfigFile, '<?php return ' . var_export($config, true) . ';');
}
}
return $config;
return (new ConfigManager([
Common\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ZendConfigProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
], 'data/cache/app_config.php'))->getMergedConfig();

View file

@ -16,6 +16,6 @@ if (class_exists(Dotenv::class)) {
// Build container
$config = require __DIR__ . '/config.php';
$container = new ServiceManager($config['services']);
$container = new ServiceManager($config['dependencies']);
$container->setService('config', $config);
return $container;

19
data/docs/installation.md Normal file
View file

@ -0,0 +1,19 @@
### Installation steps
- Define ENV vars in apache or nginx:
- SHORTENED_URL_SCHEMA: http|https
- SHORTENED_URL_HOSTNAME: Short domain
- SHORTCODE_CHARS: The char set used to generate short codes (defaults to **123456789bcdfghjkmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ**, but a new one can be generated with the `config:generate-charset` command)
- DB_USER: MySQL database user
- DB_PASSWORD: MySQL database password
- REST_USER: Username for REST authentication
- REST_PASSWORD: Password for REST authentication
- DB_NAME: MySQL database name (defaults to **shlink**)
- DEFAULT_LOCALE: Language in which web requests (browser and REST) will be returned if no `Accept-Language` header is sent (defaults to **en**)
- CLI_LOCALE: Language in which console command messages will be displayed (defaults to **en**)
- Create database (`vendor/bin/doctrine orm:schema-tool:create`)
- Add write permissions to `data` directory
- Create doctrine proxies (`vendor/bin/doctrine orm:generate-proxies`)
- Create symlink to bin/cli as `shlink` in /usr/local/bin (linux only. Optional)
Supported languages: es and en

View file

@ -13,7 +13,15 @@ Statuses:
## Authentication
[TODO]
Once you have called to the authentication endpoint for the first time (see below) yopu will get an authentication token.
You will have to send that token in the `X-Auth-Token` header on any later request or you will get an authentication error.
## Language
In order to set the application language, you have to pass it by using the `Accept-Language` header.
If not provided or provided language is not supported, english (en_US) will be used.
## Endpoints
@ -222,9 +230,12 @@ Posible errors:
**REQUEST**
* `GET` -> `/rest/visits/{shortCode}`
* `GET` -> `/rest/short-codes/{shortCode}/visits`
* Route params:
* shortCode: `string` -> The shortCode from which we eant to get the visits.
* Query params:
* startDate: `string` -> If provided, only visits older that this date will be returned
* endDate: `string` -> If provided, only visits newer that this date will be returned
* Headers:
* X-Auth-Token: `string` -> The token provided in the authentication request

View file

@ -1,5 +1,5 @@
<?php
use Acelaya\UrlShortener\CLI\Command;
use Shlinkio\Shlink\CLI\Command;
return [
@ -9,6 +9,8 @@ return [
Command\ResolveUrlCommand::class,
Command\ListShortcodesCommand::class,
Command\GetVisitsCommand::class,
Command\ProcessVisitsCommand::class,
Command\Config\GenerateCharsetCommand::class,
]
],

View file

@ -0,0 +1,23 @@
<?php
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Shlinkio\Shlink\CLI\Command;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Symfony\Component\Console\Application;
return [
'dependencies' => [
'factories' => [
Application::class => ApplicationFactory::class,
Command\GenerateShortcodeCommand::class => AnnotatedFactory::class,
Command\ResolveUrlCommand::class => AnnotatedFactory::class,
Command\ListShortcodesCommand::class => AnnotatedFactory::class,
Command\GetVisitsCommand::class => AnnotatedFactory::class,
Command\ProcessVisitsCommand::class => AnnotatedFactory::class,
Command\ProcessVisitsCommand::class => AnnotatedFactory::class,
Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class,
],
],
];

View file

@ -0,0 +1,14 @@
<?php
return [
'translator' => [
'translation_file_patterns' => [
[
'type' => 'gettext',
'base_dir' => __DIR__ . '/../lang',
'pattern' => '%s.mo',
],
],
],
];

BIN
module/CLI/lang/es.mo Normal file

Binary file not shown.

147
module/CLI/lang/es.po Normal file
View file

@ -0,0 +1,147 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2016-08-01 21:21+0200\n"
"PO-Revision-Date: 2016-08-01 21:22+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.8.7.1\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: translate;translatePlural\n"
"X-Poedit-SearchPath-0: src\n"
"X-Poedit-SearchPath-1: config\n"
#, php-format
msgid ""
"Generates a character set sample just by shuffling the default one, \"%s\". "
"Then it can be set in the SHORTCODE_CHARS environment variable"
msgstr ""
"Genera un grupo de caracteres simplemente mexclando el grupo por defecto \"%s"
"\". Después puede ser utilizado en la variable de entrono SHORTCODE_CHARS"
msgid "Character set:"
msgstr "Grupo de caracteres:"
#, fuzzy
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"
msgid "The long URL to parse"
msgstr "La URL larga a procesar"
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?"
msgid "A URL was not provided!"
msgstr "¡No se ha proporcionado una URL!"
msgid "Processed URL:"
msgstr "URL procesada:"
msgid "Generated URL:"
msgstr "URL generada:"
#, php-format
msgid "Provided URL \"%s\" is invalid. Try with a different one."
msgstr "La URL proporcionada \"%s\" e inválida. Prueba con una diferente."
msgid "Returns the detailed visits information for provided short code"
msgstr ""
"Devuelve la información detallada de visitas para el código corto "
"proporcionado"
msgid "The short code which visits we want to get"
msgstr "El código corto del cual queremos obtener las visitas"
msgid "Allows to filter visits, returning only those older than start date"
msgstr ""
"Permite filtrar las visitas, devolviendo sólo aquellas más antiguas que "
"startDate"
msgid "Allows to filter visits, returning only those newer than end date"
msgstr ""
"Permite filtrar las visitas, devolviendo sólo aquellas más nuevas que endDate"
msgid "A short code was not provided. Which short code do you want to use?:"
msgstr "No se prporcionó un código corto. ¿Qué código corto deseas usar?"
msgid "Referer"
msgstr "Origen"
msgid "Date"
msgstr "Fecha"
msgid "Remote Address"
msgstr "Dirección remota"
msgid "User agent"
msgstr "Agente de usuario"
msgid "List all short URLs"
msgstr "Listar todas las URLs cortas"
#, php-format
msgid "The first page to list (%s items per page)"
msgstr "La primera página a listar (%s elementos por página)"
msgid "Short code"
msgstr "Código corto"
msgid "Original URL"
msgstr "URL original"
msgid "Date created"
msgstr "Fecha de creación"
msgid "Visits count"
msgstr "Número de visitas"
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"
msgid "The short code to parse"
msgstr "El código corto a convertir"
msgid "A short code was not provided. Which short code do you want to parse?:"
msgstr ""
"No se proporcionó un código corto. ¿Qué código corto quieres convertir?"
#, php-format
msgid "No URL found for short code \"%s\""
msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
msgid "Long URL:"
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."

View file

@ -0,0 +1,44 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Config;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateCharsetCommand extends Command
{
/**
* @var TranslatorInterface
*/
private $translator;
/**
* GenerateCharsetCommand constructor.
* @param TranslatorInterface $translator
*
* @Inject({"translator"})
*/
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('config:generate-charset')
->setDescription(sprintf($this->translator->translate(
'Generates a character set sample just by shuffling the default one, "%s". '
. 'Then it can be set in the SHORTCODE_CHARS environment variable'
), UrlShortener::DEFAULT_CHARS));
}
public function execute(InputInterface $input, OutputInterface $output)
{
$charSet = str_shuffle(UrlShortener::DEFAULT_CHARS);
$output->writeln($this->translator->translate('Character set:') . sprintf(' <info>%s</info>', $charSet));
}
}

View file

@ -1,18 +1,18 @@
<?php
namespace Acelaya\UrlShortener\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command;
use Acelaya\UrlShortener\Exception\InvalidUrlException;
use Acelaya\UrlShortener\Service\UrlShortener;
use Acelaya\UrlShortener\Service\UrlShortenerInterface;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
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\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Zend\Diactoros\Uri;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateShortcodeCommand extends Command
{
@ -24,26 +24,37 @@ class GenerateShortcodeCommand extends Command
* @var array
*/
private $domainConfig;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* GenerateShortcodeCommand constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param TranslatorInterface $translator
* @param array $domainConfig
*
* @Inject({UrlShortener::class, "config.url_shortener.domain"})
* @Inject({UrlShortener::class, "translator", "config.url_shortener.domain"})
*/
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
{
parent::__construct(null);
public function __construct(
UrlShortenerInterface $urlShortener,
TranslatorInterface $translator,
array $domainConfig
) {
$this->urlShortener = $urlShortener;
$this->translator = $translator;
$this->domainConfig = $domainConfig;
parent::__construct(null);
}
public function configure()
{
$this->setName('shortcode:generate')
->setDescription('Generates a shortcode for provided URL and returns the short URL')
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse');
->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'));
}
public function interact(InputInterface $input, OutputInterface $output)
@ -55,9 +66,10 @@ class GenerateShortcodeCommand extends Command
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new Question(
'<question>A long URL was not provided. Which URL do you want to shorten?:</question> '
);
$question = new Question(sprintf(
'<question>%s</question> ',
$this->translator->translate('A long URL was not provided. Which URL do you want to shorten?:')
));
$longUrl = $helper->ask($input, $output, $question);
if (! empty($longUrl)) {
@ -71,23 +83,26 @@ class GenerateShortcodeCommand extends Command
try {
if (! isset($longUrl)) {
$output->writeln('<error>A URL was not provided!</error>');
$output->writeln(sprintf('<error>%s</error>', $this->translator->translate('A URL was not provided!')));
return;
}
$shortcode = $this->urlShortener->urlToShortCode(new Uri($longUrl));
$shortUrl = (new Uri())->withPath($shortcode)
$shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl));
$shortUrl = (new Uri())->withPath($shortCode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
$output->writeln([
sprintf('Processed URL <info>%s</info>', $longUrl),
sprintf('Generated URL <info>%s</info>', $shortUrl),
sprintf('%s <info>%s</info>', $this->translator->translate('Processed URL:'), $longUrl),
sprintf('%s <info>%s</info>', $this->translator->translate('Generated URL:'), $shortUrl),
]);
} catch (InvalidUrlException $e) {
$output->writeln(
sprintf('<error>Provided URL "%s" is invalid. Try with a different one.</error>', $longUrl)
);
$output->writeln(sprintf(
'<error>' . $this->translator->translate(
'Provided URL "%s" is invalid. Try with a different one.'
) . '</error>',
$longUrl
));
}
}
}

View file

@ -0,0 +1,122 @@
<?php
namespace Shlinkio\Shlink\CLI\Command;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Helper\Table;
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\I18n\Translator\TranslatorInterface;
class GetVisitsCommand extends Command
{
/**
* @var VisitsTrackerInterface
*/
private $visitsTracker;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* GetVisitsCommand constructor.
* @param VisitsTrackerInterface|VisitsTracker $visitsTracker
* @param TranslatorInterface $translator
*
* @Inject({VisitsTracker::class, "translator"})
*/
public function __construct(VisitsTrackerInterface $visitsTracker, TranslatorInterface $translator)
{
$this->visitsTracker = $visitsTracker;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('shortcode:visits')
->setDescription(
$this->translator->translate('Returns the detailed visits information for provided short code')
)
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code which visits we want to get')
)
->addOption(
'startDate',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('Allows to filter visits, returning only those older than start date')
)
->addOption(
'endDate',
'e',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('Allows to filter visits, returning only those newer than end date')
);
}
public function interact(InputInterface $input, OutputInterface $output)
{
$shortCode = $input->getArgument('shortCode');
if (! empty($shortCode)) {
return;
}
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new Question(sprintf(
'<question>%s</question> ',
$this->translator->translate('A short code was not provided. Which short code do you want to use?:')
));
$shortCode = $helper->ask($input, $output, $question);
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
public function execute(InputInterface $input, OutputInterface $output)
{
$shortCode = $input->getArgument('shortCode');
$startDate = $this->getDateOption($input, 'startDate');
$endDate = $this->getDateOption($input, 'endDate');
$visits = $this->visitsTracker->info($shortCode, new DateRange($startDate, $endDate));
$table = new Table($output);
$table->setHeaders([
$this->translator->translate('Referer'),
$this->translator->translate('Date'),
$this->translator->translate('Remote Address'),
$this->translator->translate('User agent'),
]);
foreach ($visits as $row) {
$rowData = $row->jsonSerialize();
// Unset location info
unset($rowData['visitLocation']);
$table->addRow(array_values($rowData));
}
$table->render();
}
protected function getDateOption(InputInterface $input, $key)
{
$value = $input->getOption($key);
if (isset($value)) {
$value = new \DateTime($value);
}
return $value;
}
}

View file

@ -1,11 +1,11 @@
<?php
namespace Acelaya\UrlShortener\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command;
use Acelaya\UrlShortener\Paginator\Adapter\PaginableRepositoryAdapter;
use Acelaya\UrlShortener\Paginator\Util\PaginatorUtilsTrait;
use Acelaya\UrlShortener\Service\ShortUrlService;
use Acelaya\UrlShortener\Service\ShortUrlServiceInterface;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Helper\Table;
@ -13,6 +13,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Zend\I18n\Translator\TranslatorInterface;
class ListShortcodesCommand extends Command
{
@ -22,28 +23,37 @@ class ListShortcodesCommand extends Command
* @var ShortUrlServiceInterface
*/
private $shortUrlService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* ListShortcodesCommand constructor.
* @param ShortUrlServiceInterface|ShortUrlService $shortUrlService
* @param TranslatorInterface $translator
*
* @Inject({ShortUrlService::class})
* @Inject({ShortUrlService::class, "translator"})
*/
public function __construct(ShortUrlServiceInterface $shortUrlService)
public function __construct(ShortUrlServiceInterface $shortUrlService, TranslatorInterface $translator)
{
parent::__construct(null);
$this->shortUrlService = $shortUrlService;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('shortcode:list')
->setDescription('List all short URLs')
->setDescription($this->translator->translate('List all short URLs'))
->addOption(
'page',
'p',
InputOption::VALUE_OPTIONAL,
sprintf('The first page to list (%s items per page)', PaginableRepositoryAdapter::ITEMS_PER_PAGE),
sprintf(
$this->translator->translate('The first page to list (%s items per page)'),
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
);
}
@ -59,10 +69,10 @@ class ListShortcodesCommand extends Command
$page++;
$table = new Table($output);
$table->setHeaders([
'Short code',
'Original URL',
'Date created',
'Visits count',
$this->translator->translate('Short code'),
$this->translator->translate('Original URL'),
$this->translator->translate('Date created'),
$this->translator->translate('Visits count'),
]);
foreach ($result as $row) {
@ -72,10 +82,14 @@ class ListShortcodesCommand extends Command
if ($this->isLastPage($result)) {
$continue = false;
$output->writeln('<info>You have reached last page</info>');
$output->writeln(
sprintf('<info>%s</info>', $this->translator->translate('You have reached last page'))
);
} else {
$continue = $helper->ask($input, $output, new ConfirmationQuestion(
sprintf('<question>Continue with page <bg=cyan;options=bold>%s</>? (y/N)</question> ', $page),
sprintf('<question>' . $this->translator->translate(
'Continue with page'
) . ' <bg=cyan;options=bold>%s</>? (y/N)</question> ', $page),
false
));
}

View file

@ -0,0 +1,91 @@
<?php
namespace Shlinkio\Shlink\CLI\Command;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service\IpLocationResolverInterface;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Service\VisitService;
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command
{
const LOCALHOST = '127.0.0.1';
/**
* @var VisitServiceInterface
*/
private $visitService;
/**
* @var IpLocationResolverInterface
*/
private $ipLocationResolver;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* ProcessVisitsCommand constructor.
* @param VisitServiceInterface|VisitService $visitService
* @param IpLocationResolverInterface|IpLocationResolver $ipLocationResolver
* @param TranslatorInterface $translator
*
* @Inject({VisitService::class, IpLocationResolver::class, "translator"})
*/
public function __construct(
VisitServiceInterface $visitService,
IpLocationResolverInterface $ipLocationResolver,
TranslatorInterface $translator
) {
$this->visitService = $visitService;
$this->ipLocationResolver = $ipLocationResolver;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('visit:process')
->setDescription(
$this->translator->translate('Processes visits where location is not set yet')
);
}
public function execute(InputInterface $input, OutputInterface $output)
{
$visits = $this->visitService->getUnlocatedVisits();
foreach ($visits as $visit) {
$ipAddr = $visit->getRemoteAddr();
$output->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === self::LOCALHOST) {
$output->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);
continue;
}
try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
$location = new VisitLocation();
$location->exchangeArray($result);
$visit->setVisitLocation($location);
$this->visitService->saveVisit($visit);
$output->writeln(sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName()
));
} catch (WrongIpException $e) {
continue;
}
}
$output->writeln($this->translator->translate('Finished processing all IPs'));
}
}

View file

@ -1,16 +1,17 @@
<?php
namespace Acelaya\UrlShortener\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command;
use Acelaya\UrlShortener\Exception\InvalidShortCodeException;
use Acelaya\UrlShortener\Service\UrlShortener;
use Acelaya\UrlShortener\Service\UrlShortenerInterface;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
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\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Zend\I18n\Translator\TranslatorInterface;
class ResolveUrlCommand extends Command
{
@ -18,24 +19,34 @@ class ResolveUrlCommand extends Command
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* ResolveUrlCommand constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param TranslatorInterface $translator
*
* @Inject({UrlShortener::class})
* @Inject({UrlShortener::class, "translator"})
*/
public function __construct(UrlShortenerInterface $urlShortener)
public function __construct(UrlShortenerInterface $urlShortener, TranslatorInterface $translator)
{
parent::__construct(null);
$this->urlShortener = $urlShortener;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('shortcode:parse')
->setDescription('Returns the long URL behind a short code')
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse');
->setDescription($this->translator->translate('Returns the long URL behind a short code'))
->addArgument(
'shortCode',
InputArgument::REQUIRED,
$this->translator->translate('The short code to parse')
);
}
public function interact(InputInterface $input, OutputInterface $output)
@ -47,9 +58,10 @@ class ResolveUrlCommand extends Command
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new Question(
'<question>A short code was not provided. Which short code do you want to parse?:</question> '
);
$question = new Question(sprintf(
'<question>%s</question> ',
$this->translator->translate('A short code was not provided. Which short code do you want to parse?:')
));
$shortCode = $helper->ask($input, $output, $question);
if (! empty($shortCode)) {
@ -64,15 +76,18 @@ class ResolveUrlCommand extends Command
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($longUrl)) {
$output->writeln(sprintf('<error>No URL found for short code "%s"</error>', $shortCode));
$output->writeln(sprintf(
'<error>' . $this->translator->translate('No URL found for short code "%s"') . '</error>',
$shortCode
));
return;
}
$output->writeln(sprintf('Long URL <info>%s</info>', $longUrl));
$output->writeln(sprintf('%s <info>%s</info>', $this->translator->translate('Long URL:'), $longUrl));
} catch (InvalidShortCodeException $e) {
$output->writeln(
sprintf('<error>Provided short code "%s" has an invalid format.</error>', $shortCode)
);
$output->writeln(sprintf('<error>' . $this->translator->translate(
'Provided short code "%s" has an invalid format.'
) . '</error>', $shortCode));
}
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Shlinkio\Shlink\CLI;
use Zend\Config\Factory;
use Zend\Stdlib\Glob;
class ConfigProvider
{
public function __invoke()
{
return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
}
}

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\CLI\Factory;
namespace Shlinkio\Shlink\CLI\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
@ -25,7 +25,7 @@ class ApplicationFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['cli'];
$app = new CliApp();
$app = new CliApp('Shlink', '1.0.0');
$commands = isset($config['commands']) ? $config['commands'] : [];
foreach ($commands as $command) {

View file

@ -0,0 +1,56 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Config;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\CLI\Command\Config\GenerateCharsetCommand;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class GenerateCharsetCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
public function setUp()
{
$command = new GenerateCharsetCommand(Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function charactersAreGeneratedFromDefault()
{
$prefix = 'Character set: ';
$prefixLength = strlen($prefix);
$this->commandTester->execute([
'command' => 'config:generate-charset',
]);
$output = $this->commandTester->getDisplay();
// Both default character set and the new one should have the same length
$this->assertEquals($prefixLength + strlen(UrlShortener::DEFAULT_CHARS) + 1, strlen($output));
// Both default character set and the new one should have the same characters
$charset = substr($output, $prefixLength, strlen(UrlShortener::DEFAULT_CHARS));
$orderedDefault = $this->orderStringLetters(UrlShortener::DEFAULT_CHARS);
$orderedCharset = $this->orderStringLetters($charset);
$this->assertEquals($orderedDefault, $orderedCharset);
}
protected function orderStringLetters($string)
{
$letters = str_split($string);
sort($letters);
return implode('', $letters);
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\GenerateShortcodeCommand;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class GenerateShortcodeCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $urlShortener;
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new GenerateShortcodeCommand($this->urlShortener->reveal(), Translator::factory([]), [
'schema' => 'http',
'hostname' => 'foo.com'
]);
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
{
$this->urlShortener->urlToShortCode(Argument::any())->willReturn('abc123')
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/foo/bar'
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'http://foo.com/abc123') > 0);
}
/**
* @test
*/
public function exceptionWhileParsingLongUrlOutputsError()
{
$this->urlShortener->urlToShortCode(Argument::any())->willThrow(new InvalidUrlException())
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:generate',
'longUrl' => 'http://domain.com/invalid'
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(
strpos($output, 'Provided URL "http://domain.com/invalid" is invalid. Try with a different one.') === 0
);
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\GetVisitsCommand;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class GetVisitsCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $visitsTracker;
public function setUp()
{
$this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
$command = new GetVisitsCommand($this->visitsTracker->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function noDateFlagsTriesToListWithoutDateRange()
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, new DateRange(null, null))->willReturn([])
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
]);
}
/**
* @test
*/
public function providingDateFlagsTheListGetsFiltered()
{
$shortCode = 'abc123';
$startDate = '2016-01-01';
$endDate = '2016-02-01';
$this->visitsTracker->info($shortCode, new DateRange(new \DateTime($startDate), new \DateTime($endDate)))
->willReturn([])
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
'--startDate' => $startDate,
'--endDate' => $endDate,
]);
}
/**
* @test
*/
public function outputIsProperlyGenerated()
{
$shortCode = 'abc123';
$this->visitsTracker->info($shortCode, Argument::any())->willReturn([
(new Visit())->setReferer('foo')
->setRemoteAddr('1.2.3.4')
->setUserAgent('bar'),
])->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:visits',
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'foo') > 0);
$this->assertTrue(strpos($output, '1.2.3.4') > 0);
$this->assertTrue(strpos($output, 'bar') > 0);
}
}

View file

@ -0,0 +1,119 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ListShortcodesCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
class ListShortcodesCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var QuestionHelper
*/
protected $questionHelper;
/**
* @var ObjectProphecy
*/
protected $shortUrlService;
public function setUp()
{
$this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
$app = new Application();
$command = new ListShortcodesCommand($this->shortUrlService->reveal(), Translator::factory([]));
$app->add($command);
$this->questionHelper = $command->getHelper('question');
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function noInputCallsListJustOnce()
{
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->execute(['command' => 'shortcode:list']);
}
/**
* @test
*/
public function loadingMorePagesCallsListMoreTimes()
{
// The paginator will return more than one page for the first 3 times
$data = [];
for ($i = 0; $i < 30; $i++) {
$data[] = new ShortUrl();
}
$data = array_chunk($data, 11);
$questionHelper = $this->questionHelper;
$that = $this;
$this->shortUrlService->listShortUrls(Argument::any())->will(function () use (&$data, $questionHelper, $that) {
$questionHelper->setInputStream($that->getInputStream('y'));
return new Paginator(new ArrayAdapter(array_shift($data)));
})->shouldBeCalledTimes(3);
$this->commandTester->execute(['command' => 'shortcode:list']);
}
/**
* @test
*/
public function havingMorePagesButAnsweringNoCallsListJustOnce()
{
// The paginator will return more than one page
$data = [];
for ($i = 0; $i < 30; $i++) {
$data[] = new ShortUrl();
}
$this->questionHelper->setInputStream($this->getInputStream('n'));
$this->shortUrlService->listShortUrls(Argument::any())->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(1);
$this->commandTester->execute(['command' => 'shortcode:list']);
}
/**
* @test
*/
public function passingPageWillMakeListStartOnThatPage()
{
$page = 5;
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls($page)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--page' => $page,
]);
}
protected function getInputStream($inputData)
{
$stream = fopen('php://memory', 'r+', false);
fputs($stream, $inputData);
rewind($stream);
return $stream;
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class ProcessVisitsCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $visitService;
/**
* @var ObjectProphecy
*/
protected $ipResolver;
public function setUp()
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpLocationResolver::class);
$command = new ProcessVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
Translator::factory([])
);
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function allReturnedVisitsIpsAreProcessed()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
];
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits));
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits));
$this->commandTester->execute([
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Processing IP 1.2.3.4') === 0);
$this->assertTrue(strpos($output, 'Processing IP 4.3.2.1') > 0);
$this->assertTrue(strpos($output, 'Processing IP 12.34.56.78') > 0);
}
/**
* @test
*/
public function localhostAddressIsIgnored()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('127.0.0.1'),
(new Visit())->setRemoteAddr('127.0.0.1'),
];
$this->visitService->getUnlocatedVisits()->willReturn($visits)
->shouldBeCalledTimes(1);
$this->visitService->saveVisit(Argument::any())->shouldBeCalledTimes(count($visits) - 2);
$this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits) - 2);
$this->commandTester->execute([
'command' => 'visit:process',
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class ResolveUrlCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $urlShortener;
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$command = new ResolveUrlCommand($this->urlShortener->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function correctShortCodeResolvesUrl()
{
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn($expectedUrl)
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
/**
* @test
*/
public function incorrectShortCodeOutputsErrorMessage()
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertEquals('No URL found for short code "' . $shortCode . '"' . PHP_EOL, $output);
}
/**
* @test
*/
public function wrongShortCodeFormatOutputsErrorMessage()
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(new InvalidShortCodeException())
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:parse',
'shortCode' => $shortCode,
]);
$output = $this->commandTester->getDisplay();
$this->assertEquals('Provided short code "' . $shortCode . '" has an invalid format.' . PHP_EOL, $output);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace ShlinkioTest\Shlink\CLI;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\CLI\ConfigProvider;
class ConfigProviderTest extends TestCase
{
/**
* @var ConfigProvider
*/
protected $configProvider;
public function setUp()
{
$this->configProvider = new ConfigProvider();
}
/**
* @test
*/
public function confiIsProperlyReturned()
{
$config = $this->configProvider->__invoke();
$this->assertArrayHasKey('cli', $config);
$this->assertArrayHasKey('dependencies', $config);
$this->assertArrayHasKey('translator', $config);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Factory;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Zend\ServiceManager\ServiceManager;
class ApplicationFactoryTest extends TestCase
{
/**
* @var ApplicationFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new ApplicationFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke($this->createServiceManager(), '');
$this->assertInstanceOf(Application::class, $instance);
}
/**
* @test
*/
public function allCommandsWhichAreServicesAreAdded()
{
$sm = $this->createServiceManager([
'commands' => [
'foo',
'bar',
'baz',
],
]);
$sm->setService('foo', $this->prophesize(Command::class)->reveal());
$sm->setService('baz', $this->prophesize(Command::class)->reveal());
/** @var Application $instance */
$instance = $this->factory->__invoke($sm, '');
$this->assertInstanceOf(Application::class, $instance);
$this->assertCount(2, $instance->all());
}
protected function createServiceManager($config = [])
{
return new ServiceManager(['services' => [
'config' => [
'cli' => $config,
],
]]);
}
}

View file

@ -0,0 +1,38 @@
<?php
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use Shlinkio\Shlink\Common\ErrorHandler;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Shlinkio\Shlink\Common\Factory\EntityManagerFactory;
use Shlinkio\Shlink\Common\Factory\TranslatorFactory;
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Twig\Extension\TranslatorExtension;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'dependencies' => [
'factories' => [
EntityManager::class => EntityManagerFactory::class,
GuzzleHttp\Client::class => InvokableFactory::class,
Cache::class => CacheFactory::class,
IpLocationResolver::class => AnnotatedFactory::class,
Translator::class => TranslatorFactory::class,
TranslatorExtension::class => AnnotatedFactory::class,
LocaleMiddleware::class => AnnotatedFactory::class,
ErrorHandler\ContentBasedErrorHandler::class => AnnotatedFactory::class,
ErrorHandler\ErrorHandlerManager::class => ErrorHandler\ErrorHandlerManagerFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleHttp\Client::class,
'translator' => Translator::class,
AnnotatedFactory::CACHE_SERVICE => Cache::class,
],
],
];

View file

@ -0,0 +1,22 @@
<?php
use Shlinkio\Shlink\Common\ErrorHandler\ContentBasedErrorHandler;
use Zend\Expressive\Container\TemplatedErrorHandlerFactory;
use Zend\Stratigility\FinalHandler;
return [
'error_handler' => [
'plugins' => [
'invokables' => [
'text/plain' => FinalHandler::class,
],
'factories' => [
ContentBasedErrorHandler::DEFAULT_CONTENT => TemplatedErrorHandlerFactory::class,
],
'aliases' => [
'application/xhtml+xml' => ContentBasedErrorHandler::DEFAULT_CONTENT,
],
],
],
];

View file

@ -0,0 +1,15 @@
<?php
use Shlinkio\Shlink\Common\Middleware;
return [
'middleware_pipeline' => [
'pre-routing' => [
'middleware' => [
Middleware\LocaleMiddleware::class,
],
'priority' => 5,
],
],
];

View file

@ -0,0 +1,12 @@
<?php
use Shlinkio\Shlink\Common\Twig\Extension\TranslatorExtension;
return [
'twig' => [
'extensions' => [
TranslatorExtension::class,
],
],
];

View file

@ -0,0 +1,36 @@
<?php
if (! function_exists('env')) {
/**
* Gets the value of an environment variable. Supports boolean, empty and null.
* This is basically Laravel's env helper
*
* @param string $key
* @param mixed $default
* @return mixed
* @link https://github.com/laravel/framework/blob/5.2/src/Illuminate/Foundation/helpers.php#L369
*/
function env($key, $default = null)
{
$value = getenv($key);
if ($value === false) {
return $default;
}
switch (strtolower($value)) {
case 'true':
case '(true)':
return true;
case 'false':
case '(false)':
return false;
case 'empty':
case '(empty)':
return '';
case 'null':
case '(null)':
return null;
}
return trim($value);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Shlinkio\Shlink\Common;
use Zend\Config\Factory;
use Zend\Stdlib\Glob;
class ConfigProvider
{
public function __invoke()
{
return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
}
}

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\Entity;
namespace Shlinkio\Shlink\Common\Entity;
use Doctrine\ORM\Mapping as ORM;

View file

@ -0,0 +1,76 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
class ContentBasedErrorHandler implements ErrorHandlerInterface
{
const DEFAULT_CONTENT = 'text/html';
/**
* @var ErrorHandlerManagerInterface
*/
private $errorHandlerManager;
/**
* ContentBasedErrorHandler constructor.
* @param ErrorHandlerManagerInterface|ErrorHandlerManager $errorHandlerManager
*
* @Inject({ErrorHandlerManager::class})
*/
public function __construct(ErrorHandlerManagerInterface $errorHandlerManager)
{
$this->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
));
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
interface ErrorHandlerInterface
{
/**
* 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);
}

View file

@ -0,0 +1,21 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
use Zend\ServiceManager\AbstractPluginManager;
use Zend\ServiceManager\Exception\InvalidServiceException;
class ErrorHandlerManager extends AbstractPluginManager implements ErrorHandlerManagerInterface
{
public function validate($instance)
{
if (is_callable($instance)) {
return;
}
throw new InvalidServiceException(sprintf(
'Only callables are valid plugins for "%s". "%s" provided',
__CLASS__,
is_object($instance) ? get_class($instance) : gettype($instance)
));
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class ErrorHandlerManagerFactory 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')['error_handler'];
$plugins = isset($config['plugins']) ? $config['plugins'] : [];
return new ErrorHandlerManager($container, $plugins);
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
use Interop\Container\ContainerInterface;
interface ErrorHandlerManagerInterface extends ContainerInterface
{
}

View file

@ -0,0 +1,6 @@
<?php
namespace Shlinkio\Shlink\Common\Exception;
interface ExceptionInterface
{
}

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\Exception;
namespace Shlinkio\Shlink\Common\Exception;
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\Exception;
namespace Shlinkio\Shlink\Common\Exception;
class RuntimeException extends \RuntimeException implements ExceptionInterface
{

View file

@ -0,0 +1,10 @@
<?php
namespace Shlinkio\Shlink\Common\Exception;
class WrongIpException extends RuntimeException
{
public static function fromIpAddress($ipAddress, \Exception $prev = null)
{
return new self(sprintf('Provided IP "%s" is invalid', $ipAddress), 0, $prev);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class CacheFactory implements FactoryInterface
{
const VALID_CACHE_ADAPTERS = [
ApcuCache::class,
ArrayCache::class,
];
/**
* 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)
{
// Try to get the adapter from config
$config = $container->get('config');
if (isset($config['cache'])
&& isset($config['cache']['adapter'])
&& in_array($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)
) {
return new $config['cache']['adapter']();
}
// If the adapter has not been set in config, create one based on environment
return env('APP_ENV', 'pro') === 'pro' ? new ApcuCache() : new ArrayCache();
}
}

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\Factory;
namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
@ -33,7 +33,7 @@ class EntityManagerFactory implements FactoryInterface
$dbConfig = isset($globalConfig['database']) ? $globalConfig['database'] : [];
return EntityManager::create($dbConfig, Setup::createAnnotationMetadataConfiguration(
['src/Entity'],
['module/Core/src/Entity'],
$isDevMode,
'data/proxies',
$cache,

View file

@ -1,15 +1,14 @@
<?php
namespace Acelaya\UrlShortener\Factory;
namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class CacheFactory implements FactoryInterface
class TranslatorFactory implements FactoryInterface
{
/**
* Create an object
@ -25,6 +24,7 @@ class CacheFactory implements FactoryInterface
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
return getenv('APP_ENV') === 'pro' ? new ApcuCache() : new ArrayCache();
$config = $container->get('config');
return Translator::factory(isset($config['translator']) ? $config['translator'] : []);
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace Shlinkio\Shlink\Common\Middleware;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\I18n\Translator\Translator;
use Zend\Stratigility\MiddlewareInterface;
class LocaleMiddleware implements MiddlewareInterface
{
/**
* @var Translator
*/
private $translator;
/**
* LocaleMiddleware constructor.
* @param Translator $translator
*
* @Inject({"translator"})
*/
public function __construct(Translator $translator)
{
$this->translator = $translator;
}
/**
* 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)
{
if (! $request->hasHeader('Accept-Language')) {
return $out($request, $response);
}
$locale = $request->getHeaderLine('Accept-Language');
$this->translator->setLocale($this->normalizeLocale($locale));
return $out($request, $response);
}
/**
* @param string $locale
* @return string
*/
protected function normalizeLocale($locale)
{
$parts = explode('_', $locale);
if (count($parts) > 1) {
return $parts[0];
}
$parts = explode('-', $locale);
if (count($parts) > 1) {
return $parts[0];
}
return $locale;
}
}

View file

@ -1,7 +1,7 @@
<?php
namespace Acelaya\UrlShortener\Paginator\Adapter;
namespace Shlinkio\Shlink\Common\Paginator\Adapter;
use Acelaya\UrlShortener\Repository\PaginableRepositoryInterface;
use Shlinkio\Shlink\Common\Repository\PaginableRepositoryInterface;
use Zend\Paginator\Adapter\AdapterInterface;
class PaginableRepositoryAdapter implements AdapterInterface

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\Paginator\Util;
namespace Shlinkio\Shlink\Common\Paginator\Util;
use Zend\Paginator\Paginator;
use Zend\Stdlib\ArrayUtils;

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\Repository;
namespace Shlinkio\Shlink\Common\Repository;
interface PaginableRepositoryInterface
{

View file

@ -0,0 +1,42 @@
<?php
namespace Shlinkio\Shlink\Common\Service;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class IpLocationResolver implements IpLocationResolverInterface
{
const SERVICE_PATTERN = 'http://freegeoip.net/json/%s';
/**
* @var Client
*/
private $httpClient;
/**
* IpLocationResolver constructor.
* @param Client $httpClient
*
* @Inject({"httpClient"})
*/
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @param $ipAddress
* @return array
*/
public function resolveIpLocation($ipAddress)
{
try {
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
return json_decode($response->getBody(), true);
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
}
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace Shlinkio\Shlink\Common\Service;
interface IpLocationResolverInterface
{
/**
* @param $ipAddress
* @return array
*/
public function resolveIpLocation($ipAddress);
}

View file

@ -0,0 +1,75 @@
<?php
namespace Shlinkio\Shlink\Common\Twig\Extension;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Zend\I18n\Translator\TranslatorInterface;
class TranslatorExtension extends \Twig_Extension implements TranslatorInterface
{
/**
* @var TranslatorInterface
*/
private $translator;
/**
* TranslatorExtension constructor.
* @param TranslatorInterface $translator
*
* @Inject({"translator"})
*/
public function __construct(TranslatorInterface $translator)
{
$this->translator = $translator;
}
/**
* Returns the name of the extension.
*
* @return string The extension name
*/
public function getName()
{
return __CLASS__;
}
public function getFunctions()
{
return [
new \Twig_SimpleFunction('translate', [$this, 'translate']),
new \Twig_SimpleFunction('translate_plural', [$this, 'translatePlural']),
];
}
/**
* Translate a message.
*
* @param string $message
* @param string $textDomain
* @param string $locale
* @return string
*/
public function translate($message, $textDomain = 'default', $locale = null)
{
return $this->translator->translate($message, $textDomain, $locale);
}
/**
* Translate a plural message.
*
* @param string $singular
* @param string $plural
* @param int $number
* @param string $textDomain
* @param string|null $locale
* @return string
*/
public function translatePlural(
$singular,
$plural,
$number,
$textDomain = 'default',
$locale = null
) {
$this->translator->translatePlural($singular, $plural, $number, $textDomain, $locale);
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Shlinkio\Shlink\Common\Util;
class DateRange
{
/**
* @var \DateTimeInterface
*/
private $startDate;
/**
* @var \DateTimeInterface
*/
private $endDate;
public function __construct(\DateTimeInterface $startDate = null, \DateTimeInterface $endDate = null)
{
$this->startDate = $startDate;
$this->endDate = $endDate;
}
/**
* @return \DateTimeInterface
*/
public function getStartDate()
{
return $this->startDate;
}
/**
* @return \DateTimeInterface
*/
public function getEndDate()
{
return $this->endDate;
}
/**
* @return bool
*/
public function isEmpty()
{
return is_null($this->startDate) && is_null($this->endDate);
}
}

View file

@ -1,5 +1,5 @@
<?php
namespace Acelaya\UrlShortener\Util;
namespace Shlinkio\Shlink\Common\Util;
trait StringUtilsTrait
{

View file

@ -0,0 +1,31 @@
<?php
namespace ShlinkioTest\Shlink\Common;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\ConfigProvider;
class ConfigProviderTest extends TestCase
{
/**
* @var ConfigProvider
*/
protected $configProvider;
public function setUp()
{
$this->configProvider = new ConfigProvider();
}
/**
* @test
*/
public function configIsReturned()
{
$config = $this->configProvider->__invoke();
$this->assertArrayHasKey('error_handler', $config);
$this->assertArrayHasKey('middleware_pipeline', $config);
$this->assertArrayHasKey('dependencies', $config);
$this->assertArrayHasKey('twig', $config);
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace ShlinkioTest\Shlink\Common\ErrorHandler;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\ErrorHandler\ContentBasedErrorHandler;
use Shlinkio\Shlink\Common\ErrorHandler\ErrorHandlerManager;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\ServiceManager\ServiceManager;
class ContentBasedErrorHandlerTest extends TestCase
{
/**
* @var ContentBasedErrorHandler
*/
protected $errorHandler;
public function setUp()
{
$this->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());
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace ShlinkioTest\Shlink\Common\ErrorHandler;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\ErrorHandler\ErrorHandlerManager;
use Shlinkio\Shlink\Common\ErrorHandler\ErrorHandlerManagerFactory;
use Zend\ServiceManager\ServiceManager;
class ErrorHandlerManagerFactoryTest extends TestCase
{
/**
* @var ErrorHandlerManagerFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new ErrorHandlerManagerFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke(new ServiceManager(['services' => [
'config' => [
'error_handler' => [
'plugins' => [],
],
],
]]), '');
$this->assertInstanceOf(ErrorHandlerManager::class, $instance);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace ShlinkioTest\Shlink\Common\ErrorHandler;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\ErrorHandler\ErrorHandlerManager;
use Zend\ServiceManager\ServiceManager;
class ErrorHandlerManagerTest extends TestCase
{
/**
* @var ErrorHandlerManager
*/
protected $pluginManager;
public function setUp()
{
$this->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');
}
}

View file

@ -0,0 +1,76 @@
<?php
namespace ShlinkioTest\Shlink\Common\Factory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\FilesystemCache;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Zend\ServiceManager\ServiceManager;
class CacheFactoryTest extends TestCase
{
/**
* @var CacheFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new CacheFactory();
}
public static function tearDownAfterClass()
{
putenv('APP_ENV');
}
/**
* @test
*/
public function productionReturnsApcAdapter()
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke($this->createSM(), '');
$this->assertInstanceOf(ApcuCache::class, $instance);
}
/**
* @test
*/
public function developmentReturnsArrayAdapter()
{
putenv('APP_ENV=dev');
$instance = $this->factory->__invoke($this->createSM(), '');
$this->assertInstanceOf(ArrayCache::class, $instance);
}
/**
* @test
*/
public function adapterDefinedInConfigIgnoresEnvironment()
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke($this->createSM(ArrayCache::class), '');
$this->assertInstanceOf(ArrayCache::class, $instance);
}
/**
* @test
*/
public function invalidAdapterDefinedInConfigFallbacksToEnvironment()
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke($this->createSM(FilesystemCache::class), '');
$this->assertInstanceOf(ApcuCache::class, $instance);
}
private function createSM($cacheAdapter = null)
{
return new ServiceManager(['services' => [
'config' => isset($cacheAdapter) ? [
'cache' => ['adapter' => $cacheAdapter],
] : [],
]]);
}
}

View file

@ -1,9 +1,9 @@
<?php
namespace AcelayaTest\UrlShortener\Factory;
namespace ShlinkioTest\Shlink\Common\Factory;
use Acelaya\UrlShortener\Factory\EntityManagerFactory;
use Doctrine\ORM\EntityManager;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Factory\EntityManagerFactory;
use Zend\ServiceManager\ServiceManager;
class EntityManagerFactoryTest extends TestCase

View file

@ -0,0 +1,31 @@
<?php
namespace ShlinkioTest\Shlink\Common\Factory;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Factory\TranslatorFactory;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\ServiceManager;
class TranslatorFactoryTest extends TestCase
{
/**
* @var TranslatorFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new TranslatorFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke(new ServiceManager(['services' => [
'config' => [],
]]), '');
$this->assertInstanceOf(Translator::class, $instance);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace ShlinkioTest\Shlink\Common\Middleware;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
class LocaleMiddlewareTest extends TestCase
{
/**
* @var LocaleMiddleware
*/
protected $middleware;
/**
* @var Translator
*/
protected $translator;
public function setUp()
{
$this->translator = Translator::factory(['locale' => 'ru']);
$this->middleware = new LocaleMiddleware($this->translator);
}
/**
* @test
*/
public function whenNoHeaderIsPresentLocaleIsNotChanged()
{
$this->assertEquals('ru', $this->translator->getLocale());
$this->middleware->__invoke(ServerRequestFactory::fromGlobals(), new Response(), function ($req, $resp) {
return $resp;
});
$this->assertEquals('ru', $this->translator->getLocale());
}
/**
* @test
*/
public function whenTheHeaderIsPresentLocaleIsChanged()
{
$this->assertEquals('ru', $this->translator->getLocale());
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es');
$this->middleware->__invoke($request, new Response(), function ($req, $resp) {
return $resp;
});
$this->assertEquals('es', $this->translator->getLocale());
}
/**
* @test
*/
public function localeGetsNormalized()
{
$this->assertEquals('ru', $this->translator->getLocale());
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'es_ES');
$this->middleware->__invoke($request, new Response(), function ($req, $resp) {
return $resp;
});
$this->assertEquals('es', $this->translator->getLocale());
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept-Language', 'en-US');
$this->middleware->__invoke($request, new Response(), function ($req, $resp) {
return $resp;
});
$this->assertEquals('en', $this->translator->getLocale());
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace ShlinkioTest\Shlink\Common\Paginator;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Repository\PaginableRepositoryInterface;
class PaginableRepositoryAdapterTest extends TestCase
{
/**
* @var PaginableRepositoryAdapter
*/
protected $adapter;
/**
* @var ObjectProphecy
*/
protected $repo;
public function setUp()
{
$this->repo = $this->prophesize(PaginableRepositoryInterface::class);
$this->adapter = new PaginableRepositoryAdapter($this->repo->reveal(), 'search', 'order');
}
/**
* @test
*/
public function getItemsFallbacksToFindList()
{
$this->repo->findList(10, 5, 'search', 'order')->shouldBeCalledTimes(1);
$this->adapter->getItems(5, 10);
}
/**
* @test
*/
public function countFallbacksToCountList()
{
$this->repo->countList('search')->shouldBeCalledTimes(1);
$this->adapter->count();
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace ShlinkioTest\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
class IpLocationResolverTest extends TestCase
{
/**
* @var IpLocationResolver
*/
protected $ipResolver;
/**
* @var ObjectProphecy
*/
protected $client;
public function setUp()
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpLocationResolver($this->client->reveal());
}
/**
* @test
*/
public function correctIpReturnsDecodedInfo()
{
$expected = [
'foo' => 'bar',
'baz' => 'foo',
];
$response = new Response();
$response->getBody()->write(json_encode($expected));
$response->getBody()->rewind();
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
*/
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
}

View file

@ -0,0 +1,70 @@
<?php
namespace ShlinkioTest\Shlink\Common\Twig\Extension;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Twig\Extension\TranslatorExtension;
use Zend\I18n\Translator\Translator;
class TranslatorExtensionTest extends TestCase
{
/**
* @var TranslatorExtension
*/
protected $extension;
/**
* @var ObjectProphecy
*/
protected $translator;
public function setUp()
{
$this->translator = $this->prophesize(Translator::class);
$this->extension = new TranslatorExtension($this->translator->reveal());
}
/**
* @test
*/
public function extensionNameIsClassName()
{
$this->assertEquals(TranslatorExtension::class, $this->extension->getName());
}
/**
* @test
*/
public function properFunctionsAreReturned()
{
$funcs = $this->extension->getFunctions();
$this->assertCount(2, $funcs);
foreach ($funcs as $func) {
$this->assertInstanceOf(\Twig_SimpleFunction::class, $func);
}
}
/**
* @test
*/
public function translateFallbacksToTranslator()
{
$this->translator->translate('foo', 'default', null)->shouldBeCalledTimes(1);
$this->extension->translate('foo');
$this->translator->translate('bar', 'baz', 'en')->shouldBeCalledTimes(1);
$this->extension->translate('bar', 'baz', 'en');
}
/**
* @test
*/
public function translatePluralFallbacksToTranslator()
{
$this->translator->translatePlural('foo', 'bar', 'baz', 'default', null)->shouldBeCalledTimes(1);
$this->extension->translatePlural('foo', 'bar', 'baz');
$this->translator->translatePlural('foo', 'bar', 'baz', 'another', 'en')->shouldBeCalledTimes(1);
$this->extension->translatePlural('foo', 'bar', 'baz', 'another', 'en');
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace ShlinkioTest\Shlink\Common\Util;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Util\DateRange;
class DateRangeTest extends TestCase
{
/**
* @test
*/
public function defaultConstructorSetDatesToNull()
{
$range = new DateRange();
$this->assertNull($range->getStartDate());
$this->assertNull($range->getEndDate());
$this->assertTrue($range->isEmpty());
}
/**
* @test
*/
public function providedDatesAreSet()
{
$startDate = new \DateTime();
$endDate = new \DateTime();
$range = new DateRange($startDate, $endDate);
$this->assertSame($startDate, $range->getStartDate());
$this->assertSame($endDate, $range->getEndDate());
$this->assertFalse($range->isEmpty());
}
}

View file

@ -0,0 +1,21 @@
<?php
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Service;
return [
'dependencies' => [
'factories' => [
// Services
Service\UrlShortener::class => AnnotatedFactory::class,
Service\VisitsTracker::class => AnnotatedFactory::class,
Service\ShortUrlService::class => AnnotatedFactory::class,
Service\VisitService::class => AnnotatedFactory::class,
// Middleware
RedirectAction::class => AnnotatedFactory::class,
],
],
];

View file

@ -0,0 +1,15 @@
<?php
use Shlinkio\Shlink\Core\Action\RedirectAction;
return [
'routes' => [
[
'name' => 'long-url-redirect',
'path' => '/{shortCode}',
'middleware' => RedirectAction::class,
'allowed_methods' => ['GET'],
],
],
];

View file

@ -0,0 +1,11 @@
<?php
return [
'templates' => [
'paths' => [
'module/Core/templates',
],
],
];

View file

@ -0,0 +1,14 @@
<?php
return [
'translator' => [
'translation_file_patterns' => [
[
'type' => 'gettext',
'base_dir' => __DIR__ . '/../lang',
'pattern' => '%s.mo',
],
],
],
];

View file

@ -0,0 +1,12 @@
<?php
return [
'zend-expressive' => [
'error_handler' => [
'template_404' => 'core/error/404.html.twig',
'template_error' => 'core/error/error.html.twig',
],
],
];

BIN
module/Core/lang/es.mo Normal file

Binary file not shown.

35
module/Core/lang/es.po Normal file
View file

@ -0,0 +1,35 @@
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"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.8.7.1\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
"X-Poedit-KeywordsList: translate;translaePlural;translate_plural\n"
"X-Poedit-SearchPath-0: templates\n"
"X-Poedit-SearchPath-1: config\n"
"X-Poedit-SearchPath-2: src\n"
msgid "Make sure you included all the characters, with no extra punctuation."
msgstr "Asegúrate de haber incluído todos los caracteres, sin puntuación extra."
msgid "Oops!"
msgstr "¡Vaya!"
msgid "This short URL doesn't seem to be valid."
msgstr "Esta URL acortada no parece ser válida."
msgid "URL Not Found"
msgstr "URL no encontrada"
#, php-format
msgid "We encountered a %s %s error."
msgstr "Hemos encontrado un error %s %s."

View file

@ -1,17 +1,17 @@
<?php
namespace Acelaya\UrlShortener\Middleware\Routable;
namespace Shlinkio\Shlink\Core\Action;
use Acelaya\UrlShortener\Service\UrlShortener;
use Acelaya\UrlShortener\Service\UrlShortenerInterface;
use Acelaya\UrlShortener\Service\VisitsTracker;
use Acelaya\UrlShortener\Service\VisitsTrackerInterface;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Zend\Diactoros\Response\RedirectResponse;
use Zend\Stratigility\MiddlewareInterface;
class RedirectMiddleware implements MiddlewareInterface
class RedirectAction implements MiddlewareInterface
{
/**
* @var UrlShortenerInterface
@ -67,13 +67,13 @@ class RedirectMiddleware implements MiddlewareInterface
try {
$longUrl = $this->urlShortener->shortCodeToUrl($shortCode);
// If provided shortCode does not belong to a valid long URL, dispatch next middleware, which is 404
// middleware
// If provided shortCode does not belong to a valid long URL, dispatch next middleware, which will trigger
// a not-found error
if (! isset($longUrl)) {
return $out($request, $response);
return $this->notFoundResponse($request, $response, $out);
}
// Track visit to this shortcode
// Track visit to this short code
$this->visitTracker->track($shortCode);
// Return a redirect response to the long URL.
@ -81,7 +81,18 @@ class RedirectMiddleware implements MiddlewareInterface
return new RedirectResponse($longUrl);
} catch (\Exception $e) {
// In case of error, dispatch 404 error
return $out($request, $response);
return $this->notFoundResponse($request, $response, $out);
}
}
/**
* @param Request $request
* @param Response $response
* @param callable $out
* @return Response
*/
protected function notFoundResponse(Request $request, Response $response, callable $out)
{
return $out($request, $response->withStatus(404), 'Not Found');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Shlinkio\Shlink\Core;
use Zend\Config\Factory;
use Zend\Stdlib\Glob;
class ConfigProvider
{
public function __invoke()
{
return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
}
}

View file

@ -1,8 +1,9 @@
<?php
namespace Acelaya\UrlShortener\Entity;
namespace Shlinkio\Shlink\Core\Entity;
use Acelaya\UrlShortener\Util\StringUtilsTrait;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
/**
* Class RestToken

View file

@ -1,16 +1,17 @@
<?php
namespace Acelaya\UrlShortener\Entity;
namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
/**
* Class ShortUrl
* @author
* @link
*
* @ORM\Entity(repositoryClass="Acelaya\UrlShortener\Repository\ShortUrlRepository")
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\ShortUrlRepository")
* @ORM\Table(name="short_urls")
*/
class ShortUrl extends AbstractEntity implements \JsonSerializable
@ -22,7 +23,14 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
protected $originalUrl;
/**
* @var string
* @ORM\Column(name="short_code", type="string", nullable=false, length=10, unique=true)
* @ORM\Column(
* name="short_code",
* type="string",
* nullable=false,
* length=10,
* unique=true,
* options={"collation": "utf8_bin"}
* )
*/
protected $shortCode;
/**

View file

@ -1,14 +1,15 @@
<?php
namespace Acelaya\UrlShortener\Entity;
namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
/**
* Class Visit
* @author
* @link
*
* @ORM\Entity
* @ORM\Entity(repositoryClass="Shlinkio\Shlink\Core\Repository\VisitRepository")
* @ORM\Table(name="visits")
*/
class Visit extends AbstractEntity implements \JsonSerializable
@ -39,6 +40,12 @@ class Visit extends AbstractEntity implements \JsonSerializable
* @ORM\JoinColumn(name="short_url_id", referencedColumnName="id")
*/
protected $shortUrl;
/**
* @var VisitLocation
* @ORM\ManyToOne(targetEntity=VisitLocation::class, cascade={"persist"})
* @ORM\JoinColumn(name="visit_location_id", referencedColumnName="id", nullable=true)
*/
protected $visitLocation;
public function __construct()
{
@ -135,6 +142,24 @@ class Visit extends AbstractEntity implements \JsonSerializable
return $this;
}
/**
* @return VisitLocation
*/
public function getVisitLocation()
{
return $this->visitLocation;
}
/**
* @param VisitLocation $visitLocation
* @return $this
*/
public function setVisitLocation($visitLocation)
{
$this->visitLocation = $visitLocation;
return $this;
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
@ -149,6 +174,7 @@ class Visit extends AbstractEntity implements \JsonSerializable
'date' => isset($this->date) ? $this->date->format(\DateTime::ISO8601) : null,
'remoteAddr' => $this->remoteAddr,
'userAgent' => $this->userAgent,
'visitLocation' => $this->visitLocation,
];
}
}

View file

@ -0,0 +1,240 @@
<?php
namespace Shlinkio\Shlink\Core\Entity;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Doctrine\ORM\Mapping as ORM;
use Zend\Stdlib\ArraySerializableInterface;
/**
* Class VisitLocation
* @author
* @link
*
* @ORM\Entity()
* @ORM\Table(name="visit_locations")
*/
class VisitLocation extends AbstractEntity implements ArraySerializableInterface, \JsonSerializable
{
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $countryCode;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $countryName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $regionName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $cityName;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $latitude;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $longitude;
/**
* @var string
* @ORM\Column(nullable=true)
*/
protected $timezone;
/**
* @return string
*/
public function getCountryCode()
{
return $this->countryCode;
}
/**
* @param string $countryCode
* @return $this
*/
public function setCountryCode($countryCode)
{
$this->countryCode = $countryCode;
return $this;
}
/**
* @return string
*/
public function getCountryName()
{
return $this->countryName;
}
/**
* @param string $countryName
* @return $this
*/
public function setCountryName($countryName)
{
$this->countryName = $countryName;
return $this;
}
/**
* @return string
*/
public function getRegionName()
{
return $this->regionName;
}
/**
* @param string $regionName
* @return $this
*/
public function setRegionName($regionName)
{
$this->regionName = $regionName;
return $this;
}
/**
* @return string
*/
public function getCityName()
{
return $this->cityName;
}
/**
* @param string $cityName
* @return $this
*/
public function setCityName($cityName)
{
$this->cityName = $cityName;
return $this;
}
/**
* @return string
*/
public function getLatitude()
{
return $this->latitude;
}
/**
* @param string $latitude
* @return $this
*/
public function setLatitude($latitude)
{
$this->latitude = $latitude;
return $this;
}
/**
* @return string
*/
public function getLongitude()
{
return $this->longitude;
}
/**
* @param string $longitude
* @return $this
*/
public function setLongitude($longitude)
{
$this->longitude = $longitude;
return $this;
}
/**
* @return string
*/
public function getTimezone()
{
return $this->timezone;
}
/**
* @param string $timezone
* @return $this
*/
public function setTimezone($timezone)
{
$this->timezone = $timezone;
return $this;
}
/**
* Exchange internal values from provided array
*
* @param array $array
* @return void
*/
public function exchangeArray(array $array)
{
if (array_key_exists('country_code', $array)) {
$this->setCountryCode($array['country_code']);
}
if (array_key_exists('country_name', $array)) {
$this->setCountryName($array['country_name']);
}
if (array_key_exists('region_name', $array)) {
$this->setRegionName($array['region_name']);
}
if (array_key_exists('city', $array)) {
$this->setCityName($array['city']);
}
if (array_key_exists('latitude', $array)) {
$this->setLatitude($array['latitude']);
}
if (array_key_exists('longitude', $array)) {
$this->setLongitude($array['longitude']);
}
if (array_key_exists('time_zone', $array)) {
$this->setTimezone($array['time_zone']);
}
}
/**
* Return an array representation of the object
*
* @return array
*/
public function getArrayCopy()
{
return [
'countryCode' => $this->countryCode,
'countryName' => $this->countryName,
'regionName' => $this->regionName,
'cityName' => $this->cityName,
'latitude' => $this->latitude,
'longitude' => $this->longitude,
'timezone' => $this->timezone,
];
}
/**
* 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 <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
{
return $this->getArrayCopy();
}
}

View file

@ -1,5 +1,7 @@
<?php
namespace Acelaya\UrlShortener\Exception;
namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
class InvalidShortCodeException extends RuntimeException
{

View file

@ -1,5 +1,7 @@
<?php
namespace Acelaya\UrlShortener\Exception;
namespace Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
class InvalidUrlException extends RuntimeException
{

View file

@ -1,9 +1,8 @@
<?php
namespace Acelaya\UrlShortener\Repository;
namespace Shlinkio\Shlink\Core\Repository;
use Acelaya\UrlShortener\Entity\ShortUrl;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
{

View file

@ -1,7 +1,8 @@
<?php
namespace Acelaya\UrlShortener\Repository;
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Common\Persistence\ObjectRepository;
use Shlinkio\Shlink\Common\Repository\PaginableRepositoryInterface;
interface ShortUrlRepositoryInterface extends ObjectRepository, PaginableRepositoryInterface
{

Some files were not shown because too many files have changed in this diff Show more