Merge branch 'develop'

This commit is contained in:
Alejandro Celaya 2016-08-09 19:00:42 +02:00
commit ba5bd6d98c
87 changed files with 2452 additions and 835 deletions

View file

@ -1,5 +1,6 @@
# Application
APP_ENV=
SECRET_KEY=
SHORTENED_URL_SCHEMA=
SHORTENED_URL_HOSTNAME=
SHORTCODE_CHARS=
@ -12,7 +13,3 @@ CLI_LOCALE=
DB_USER=
DB_PASSWORD=
DB_NAME=
# Rest authentication
REST_USER=
REST_PASSWORD=

19
.phpstorm.meta.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace PHPSTORM_META;
use Interop\Container\ContainerInterface;
/**
* PhpStorm Container Interop code completion
*
* Add code completion for container-interop.
*
* \App\ClassName::class will automatically resolve to it's own name.
*
* Custom strings like ``"cache"`` or ``"logger"`` need to be added manually.
*/
$STATIC_METHOD_TYPES = [
ContainerInterface::get('') => [
'' == '@',
],
];

1
.travis-php.ini Normal file
View file

@ -0,0 +1 @@
extension="memcached.so"

View file

@ -10,6 +10,8 @@ php:
- 7
- 7.1
before_install: phpenv config-add .travis-php.ini
before_script:
- composer self-update
- composer install --no-interaction

View file

@ -25,7 +25,11 @@
"acelaya/zsm-annotated-services": "^0.2.0",
"doctrine/orm": "^2.5",
"guzzlehttp/guzzle": "^6.2",
"symfony/console": "^3.0"
"symfony/console": "^3.0",
"firebase/php-jwt": "^4.0",
"monolog/monolog": "^1.21",
"theorchard/monolog-cascade": "^0.4",
"endroid/qrcode": "^1.7"
},
"require-dev": {
"phpunit/phpunit": "^5.0",

View file

@ -0,0 +1,10 @@
<?php
return [
'app_options' => [
'name' => 'Shlink',
'version' => '1.1.0',
'secret_key' => env('SECRET_KEY'),
],
];

View file

@ -1,15 +0,0 @@
<?php
return [
'database' => [
'driver' => 'pdo_mysql',
'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,20 @@
<?php
return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
],
'connection' => [
'driver' => 'pdo_mysql',
'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

@ -1,7 +1,8 @@
<?php
return [
'debug' => true,
'debug' => true,
'config_cache_enabled' => false,
];

View file

@ -0,0 +1,32 @@
<?php
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
return [
'logger' => [
'formatters' => [
'dashed' => [
'format' => '[%datetime%] %channel%.%level_name% - %message% %context%' . PHP_EOL,
'include_stacktraces' => true,
],
],
'handlers' => [
'rotating_file_handler' => [
'class' => RotatingFileHandler::class,
'level' => Logger::INFO,
'filename' => 'data/log/shlink_log.log',
'max_files' => 30,
'formatter' => 'dashed',
],
],
'loggers' => [
'Shlink' => [
'handlers' => ['rotating_file_handler'],
],
],
],
];

View file

@ -0,0 +1,14 @@
<?php
use Monolog\Logger;
return [
'logger' => [
'handlers' => [
'rotating_file_handler' => [
'level' => Logger::DEBUG,
],
],
],
];

View file

@ -1,19 +0,0 @@
### 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

@ -1,289 +0,0 @@
# REST API documentation
## Error management
Statuses:
* 400 -> controlled error
* 401 -> authentication error
* 500 -> unexpected error
[TODO]
## Authentication
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
#### Authenticate
**REQUEST**
* `POST` -> `/rest/authenticate`
* Params:
* username: `string`
* password: `string`
**SUCCESS RESPONSE**
```json
{
"token": "9f741eb0-33d7-4c56-b8f7-3719e9929946"
}
```
**ERROR RESPONSE**
```json
{
"error": "INVALID_ARGUMENT",
"message": "You have to provide both \"username\" and \"password\""
}
```
Posible errors:
* **INVALID_ARGUMENT**: Username or password were not provided.
* **INVALID_CREDENTIALS**: Username or password are incorrect.
#### Create shortcode
**REQUEST**
* `POST` -> `/rest/short-codes`
* Params:
* longUrl: `string` -> The URL to shorten
* Headers:
* X-Auth-Token: `string` -> The token provided in the authentication request
**SUCCESS RESPONSE**
```json
{
"longUrl": "https://www.facebook.com/something/something",
"shortUrl": "https://doma.in/rY9Kr",
"shortCode": "rY9Kr"
}
```
**ERROR RESPONSE**
```json
{
"error": "INVALID_URL",
"message": "Provided URL \"wfwef\" is invalid. Try with a different one."
}
```
Posible errors:
* **INVALID_ARGUMENT**: The longUrl was not provided.
* **INVALID_URL**: Provided longUrl has an invalid format or does not resolve.
* **UNKNOWN_ERROR**: Something unexpected happened.
#### Resolve URL
**REQUEST**
* `GET` -> `/rest/short-codes/{shortCode}`
* Route params:
* shortCode: `string` -> The short code we want to resolve
* Headers:
* X-Auth-Token: `string` -> The token provided in the authentication request
**SUCCESS RESPONSE**
```json
{
"longUrl": "https://www.facebook.com/something/something"
}
```
**ERROR RESPONSE**
```json
{
"error": "INVALID_SHORTCODE",
"message": "Provided short code \"abc123\" has an invalid format"
}
```
Posible errors:
* **INVALID_ARGUMENT**: No longUrl was found for provided shortCode.
* **INVALID_SHORTCODE**: Provided shortCode does not match the character set used by the app to generate short codes.
* **UNKNOWN_ERROR**: Something unexpected happened.
#### List shortened URLs
**REQUEST**
* `GET` -> `/rest/short-codes`
* Query params:
* page: `integer` -> The page to list. Defaults to 1 if not provided.
* Headers:
* X-Auth-Token: `string` -> The token provided in the authentication request
**SUCCESS RESPONSE**
```json
{
"shortUrls": {
"data": [
{
"shortCode": "abc123",
"originalUrl": "http://www.alejandrocelaya.com",
"dateCreated": "2016-04-30T18:01:47+0200",
"visitsCount": 4
},
{
"shortCode": "def456",
"originalUrl": "http://www.alejandrocelaya.com/en",
"dateCreated": "2016-04-30T18:03:43+0200",
"visitsCount": 0
},
{
"shortCode": "ghi789",
"originalUrl": "http://www.alejandrocelaya.com/es",
"dateCreated": "2016-04-30T18:10:38+0200",
"visitsCount": 0
},
{
"shortCode": "jkl987",
"originalUrl": "http://www.alejandrocelaya.com/es/",
"dateCreated": "2016-04-30T18:10:57+0200",
"visitsCount": 0
},
{
"shortCode": "mno654",
"originalUrl": "http://blog.alejandrocelaya.com/2016/04/09/improving-zend-service-manager-workflow-with-annotations/",
"dateCreated": "2016-04-30T19:21:05+0200",
"visitsCount": 1
},
{
"shortCode": "pqr321",
"originalUrl": "http://www.google.com",
"dateCreated": "2016-05-01T11:19:53+0200",
"visitsCount": 0
},
{
"shortCode": "stv159",
"originalUrl": "http://www.acelaya.com",
"dateCreated": "2016-06-12T17:49:21+0200",
"visitsCount": 0
},
{
"shortCode": "wxy753",
"originalUrl": "http://www.atomic-reader.com",
"dateCreated": "2016-06-12T17:50:27+0200",
"visitsCount": 0
},
{
"shortCode": "zab852",
"originalUrl": "http://foo.com",
"dateCreated": "2016-07-03T09:07:36+0200",
"visitsCount": 0
},
{
"shortCode": "cde963",
"originalUrl": "https://www.facebook.com.com",
"dateCreated": "2016-07-03T09:12:35+0200",
"visitsCount": 0
}
],
"pagination": {
"currentPage": 4,
"pagesCount": 15
}
}
}
```
**ERROR RESPONSE**
```json
{
"error": "UNKNOWN_ERROR",
"message": "Unexpected error occured"
}
```
Posible errors:
* **UNKNOWN_ERROR**: Something unexpected happened.
#### Get visits
**REQUEST**
* `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
**SUCCESS RESPONSE**
```json
{
"shortUrls": {
"data": [
{
"referer": null,
"date": "2016-06-18T09:32:22+0200",
"remoteAddr": "127.0.0.1",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36"
},
{
"referer": null,
"date": "2016-04-30T19:20:06+0200",
"remoteAddr": "127.0.0.1",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36"
},
{
"referer": "google.com",
"date": "2016-04-30T19:19:57+0200",
"remoteAddr": "1.2.3.4",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36"
},
{
"referer": null,
"date": "2016-04-30T19:17:35+0200",
"remoteAddr": "127.0.0.1",
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.87 Safari/537.36"
}
],
}
}
```
**ERROR RESPONSE**
```json
{
"error": "INVALID_ARGUMENT",
"message": "Provided short code \"abc123\" is invalid"
}
```
Posible errors:
* **INVALID_ARGUMENT**: The shortcode does not belong to any short URL
* **UNKNOWN_ERROR**: Something unexpected happened.

2
data/log/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -5,12 +5,16 @@ return [
'cli' => [
'commands' => [
Command\GenerateShortcodeCommand::class,
Command\ResolveUrlCommand::class,
Command\ListShortcodesCommand::class,
Command\GetVisitsCommand::class,
Command\ProcessVisitsCommand::class,
Command\Shortcode\GenerateShortcodeCommand::class,
Command\Shortcode\ResolveUrlCommand::class,
Command\Shortcode\ListShortcodesCommand::class,
Command\Shortcode\GetVisitsCommand::class,
Command\Visit\ProcessVisitsCommand::class,
Command\Config\GenerateCharsetCommand::class,
Command\Config\GenerateSecretCommand::class,
Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::class,
Command\Api\ListKeysCommand::class,
]
],

View file

@ -10,13 +10,16 @@ return [
'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\Shortcode\GenerateShortcodeCommand::class => AnnotatedFactory::class,
Command\Shortcode\ResolveUrlCommand::class => AnnotatedFactory::class,
Command\Shortcode\ListShortcodesCommand::class => AnnotatedFactory::class,
Command\Shortcode\GetVisitsCommand::class => AnnotatedFactory::class,
Command\Visit\ProcessVisitsCommand::class => AnnotatedFactory::class,
Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class,
Command\Config\GenerateSecretCommand::class => AnnotatedFactory::class,
Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class,
Command\Api\DisableKeyCommand::class => AnnotatedFactory::class,
Command\Api\ListKeysCommand::class => AnnotatedFactory::class,
],
],

Binary file not shown.

View file

@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2016-08-01 21:21+0200\n"
"PO-Revision-Date: 2016-08-01 21:22+0200\n"
"POT-Creation-Date: 2016-08-07 20:16+0200\n"
"PO-Revision-Date: 2016-08-07 20:18+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@ -17,6 +17,46 @@ msgstr ""
"X-Poedit-SearchPath-0: src\n"
"X-Poedit-SearchPath-1: config\n"
msgid "Disables an API key."
msgstr "Desahbilita una clave de API."
msgid "The API key to disable"
msgstr "La clave de API a deshabilitar"
#, php-format
msgid "API key %s properly disabled"
msgstr "Clave de API %s deshabilitada correctamente"
#, php-format
msgid "API key \"%s\" does not exist."
msgstr "La clave de API \"%s\" no existe."
msgid "Generates a new valid API key."
msgstr "Genera una nueva clave de API válida."
msgid "The date in which the API key should expire. Use any valid PHP format."
msgstr ""
"La fecha en la que la clave de API debe expirar. Utiliza cualquier valor "
"válido en PHP."
msgid "Generated API key"
msgstr "Generada clave de API"
msgid "Lists all the available API keys."
msgstr "Lista todas las claves de API disponibles."
msgid "Tells if only enabled API keys should be returned."
msgstr "Define si sólo las claves de API habilitadas deben ser devueltas."
msgid "Key"
msgstr "Clave"
msgid "Expiration date"
msgstr "Fecha de caducidad"
msgid "Is enabled"
msgstr "Está habilitada"
#, php-format
msgid ""
"Generates a character set sample just by shuffling the default one, \"%s\". "

View file

@ -0,0 +1,62 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Api;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class DisableKeyCommand extends Command
{
/**
* @var ApiKeyServiceInterface
*/
private $apiKeyService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* DisableKeyCommand constructor.
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
* @param TranslatorInterface $translator
*
* @Inject({ApiKeyService::class, "translator"})
*/
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
{
$this->apiKeyService = $apiKeyService;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('api-key:disable')
->setDescription($this->translator->translate('Disables an API key.'))
->addArgument('apiKey', InputArgument::REQUIRED, $this->translator->translate('The API key to disable'));
}
public function execute(InputInterface $input, OutputInterface $output)
{
$apiKey = $input->getArgument('apiKey');
try {
$this->apiKeyService->disable($apiKey);
$output->writeln(sprintf(
$this->translator->translate('API key %s properly disabled'),
'<info>' . $apiKey . '</info>'
));
} catch (\InvalidArgumentException $e) {
$output->writeln(sprintf(
'<error>' . $this->translator->translate('API key "%s" does not exist.') . '</error>',
$apiKey
));
}
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Api;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateKeyCommand extends Command
{
/**
* @var ApiKeyServiceInterface
*/
private $apiKeyService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* GenerateKeyCommand constructor.
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
* @param TranslatorInterface $translator
*
* @Inject({ApiKeyService::class, "translator"})
*/
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
{
$this->apiKeyService = $apiKeyService;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('api-key:generate')
->setDescription($this->translator->translate('Generates a new valid API key.'))
->addOption(
'expirationDate',
'e',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('The date in which the API key should expire. Use any valid PHP format.')
);
}
public function execute(InputInterface $input, OutputInterface $output)
{
$expirationDate = $input->getOption('expirationDate');
$apiKey = $this->apiKeyService->create(isset($expirationDate) ? new \DateTime($expirationDate) : null);
$output->writeln($this->translator->translate('Generated API key') . sprintf(': <info>%s</info>', $apiKey));
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Api;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class ListKeysCommand extends Command
{
/**
* @var ApiKeyServiceInterface
*/
private $apiKeyService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* ListKeysCommand constructor.
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
* @param TranslatorInterface $translator
*
* @Inject({ApiKeyService::class, "translator"})
*/
public function __construct(ApiKeyServiceInterface $apiKeyService, TranslatorInterface $translator)
{
$this->apiKeyService = $apiKeyService;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('api-key:list')
->setDescription($this->translator->translate('Lists all the available API keys.'))
->addOption(
'enabledOnly',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Tells if only enabled API keys should be returned.')
);
}
public function execute(InputInterface $input, OutputInterface $output)
{
$enabledOnly = $input->getOption('enabledOnly');
$list = $this->apiKeyService->listKeys($enabledOnly);
$table = new Table($output);
if ($enabledOnly) {
$table->setHeaders([
$this->translator->translate('Key'),
$this->translator->translate('Expiration date'),
]);
} else {
$table->setHeaders([
$this->translator->translate('Key'),
$this->translator->translate('Is enabled'),
$this->translator->translate('Expiration date'),
]);
}
/** @var ApiKey $row */
foreach ($list as $row) {
$key = $row->getKey();
$expiration = $row->getExpirationDate();
$rowData = [];
if ($enabledOnly) {
$rowData[] = $key;
} else {
$rowData[] = $row->isEnabled() ? $this->getSuccessString($key) : $this->getErrorString($key);
$rowData[] = $row->isEnabled() ? $this->getSuccessString('+++') : $this->getErrorString('---');
}
$rowData[] = isset($expiration) ? $expiration->format(\DateTime::ISO8601) : '-';
$table->addRow($rowData);
}
$table->render();
}
/**
* @param string $string
* @return string
*/
protected function getErrorString($string)
{
return sprintf('<fg=red>%s</>', $string);
}
/**
* @param string $string
* @return string
*/
protected function getSuccessString($string)
{
return sprintf('<info>%s</info>', $string);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Config;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class GenerateSecretCommand extends Command
{
use StringUtilsTrait;
/**
* @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-secret')
->setDescription($this->translator->translate(
'Generates a random secret string that can be used for JWT token encryption'
));
}
public function execute(InputInterface $input, OutputInterface $output)
{
$secret = $this->generateRandomString(32);
$output->writeln($this->translator->translate('Secret key:') . sprintf(' <info>%s</info>', $secret));
}
}

View file

@ -1,5 +1,5 @@
<?php
namespace Shlinkio\Shlink\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
@ -31,7 +31,7 @@ class GenerateShortcodeCommand extends Command
/**
* GenerateShortcodeCommand constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param UrlShortenerInterface $urlShortener
* @param TranslatorInterface $translator
* @param array $domainConfig
*

View file

@ -1,5 +1,5 @@
<?php
namespace Shlinkio\Shlink\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Util\DateRange;
@ -28,7 +28,7 @@ class GetVisitsCommand extends Command
/**
* GetVisitsCommand constructor.
* @param VisitsTrackerInterface|VisitsTracker $visitsTracker
* @param VisitsTrackerInterface $visitsTracker
* @param TranslatorInterface $translator
*
* @Inject({VisitsTracker::class, "translator"})

View file

@ -1,5 +1,5 @@
<?php
namespace Shlinkio\Shlink\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
@ -30,7 +30,7 @@ class ListShortcodesCommand extends Command
/**
* ListShortcodesCommand constructor.
* @param ShortUrlServiceInterface|ShortUrlService $shortUrlService
* @param ShortUrlServiceInterface $shortUrlService
* @param TranslatorInterface $translator
*
* @Inject({ShortUrlService::class, "translator"})

View file

@ -1,5 +1,5 @@
<?php
namespace Shlinkio\Shlink\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
@ -26,7 +26,7 @@ class ResolveUrlCommand extends Command
/**
* ResolveUrlCommand constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param UrlShortenerInterface $urlShortener
* @param TranslatorInterface $translator
*
* @Inject({UrlShortener::class, "translator"})

View file

@ -1,5 +1,5 @@
<?php
namespace Shlinkio\Shlink\CLI\Command;
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
@ -32,8 +32,8 @@ class ProcessVisitsCommand extends Command
/**
* ProcessVisitsCommand constructor.
* @param VisitServiceInterface|VisitService $visitService
* @param IpLocationResolverInterface|IpLocationResolver $ipLocationResolver
* @param VisitServiceInterface $visitService
* @param IpLocationResolverInterface $ipLocationResolver
* @param TranslatorInterface $translator
*
* @Inject({VisitService::class, IpLocationResolver::class, "translator"})

View file

@ -0,0 +1,62 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class DisableKeyCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $apiKeyService;
public function setUp()
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new DisableKeyCommand($this->apiKeyService->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function providedApiKeyIsDisabled()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
}
/**
* @test
*/
public function errorIsReturnedIfServiceThrowsException()
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class)
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:disable',
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
$this->assertEquals('API key "abcd1234" does not exist.' . PHP_EOL, $output);
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class GenerateKeyCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $apiKeyService;
public function setUp()
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new GenerateKeyCommand($this->apiKeyService->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function noExpirationDateIsDefinedIfNotProvided()
{
$this->apiKeyService->create(null)->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:generate',
]);
}
/**
* @test
*/
public function expirationDateIsDefinedIfWhenProvided()
{
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01',
]);
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Api;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\ListKeysCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
class ListKeysCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $apiKeyService;
public function setUp()
{
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$command = new ListKeysCommand($this->apiKeyService->reveal(), Translator::factory([]));
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function ifEnabledOnlyIsNotProvidedEverythingIsListed()
{
$this->apiKeyService->listKeys(false)->willReturn([
new ApiKey(),
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:list',
]);
}
/**
* @test
*/
public function ifEnabledOnlyIsProvidedOnlyThoseKeysAreListed()
{
$this->apiKeyService->listKeys(true)->willReturn([
new ApiKey(),
new ApiKey(),
])->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'api-key:list',
'--enabledOnly' => true,
]);
}
}

View file

@ -4,7 +4,7 @@ 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\CLI\Command\Shortcode\GenerateShortcodeCommand;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;

View file

@ -4,7 +4,7 @@ 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\CLI\Command\Shortcode\GetVisitsCommand;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;

View file

@ -4,7 +4,7 @@ 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\CLI\Command\Shortcode\ListShortcodesCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application;

View file

@ -4,7 +4,7 @@ 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\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitService;

View file

@ -3,7 +3,7 @@ namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ResolveUrlCommand;
use Shlinkio\Shlink\CLI\Command\Shortcode\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;

View file

@ -2,9 +2,12 @@
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\ErrorHandler;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Shlinkio\Shlink\Common\Factory\EntityManagerFactory;
use Shlinkio\Shlink\Common\Factory\LoggerFactory;
use Shlinkio\Shlink\Common\Factory\TranslatorFactory;
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
@ -19,11 +22,15 @@ return [
EntityManager::class => EntityManagerFactory::class,
GuzzleHttp\Client::class => InvokableFactory::class,
Cache::class => CacheFactory::class,
IpLocationResolver::class => AnnotatedFactory::class,
LoggerInterface::class => LoggerFactory::class,
'Logger_Shlink' => LoggerFactory::class,
Translator::class => TranslatorFactory::class,
TranslatorExtension::class => AnnotatedFactory::class,
LocaleMiddleware::class => AnnotatedFactory::class,
IpLocationResolver::class => AnnotatedFactory::class,
ErrorHandler\ContentBasedErrorHandler::class => AnnotatedFactory::class,
ErrorHandler\ErrorHandlerManager::class => ErrorHandler\ErrorHandlerManagerFactory::class,
],
@ -31,6 +38,8 @@ return [
'em' => EntityManager::class,
'httpClient' => GuzzleHttp\Client::class,
'translator' => Translator::class,
'logger' => LoggerInterface::class,
Logger::class => LoggerInterface::class,
AnnotatedFactory::CACHE_SERVICE => Cache::class,
],
],

View file

@ -1,8 +1,7 @@
<?php
namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
@ -12,8 +11,11 @@ use Zend\ServiceManager\Factory\FactoryInterface;
class CacheFactory implements FactoryInterface
{
const VALID_CACHE_ADAPTERS = [
ApcuCache::class,
ArrayCache::class,
Cache\ApcuCache::class,
Cache\ArrayCache::class,
Cache\FilesystemCache::class,
Cache\PhpFileCache::class,
Cache\MemcachedCache::class,
];
/**
@ -36,10 +38,44 @@ class CacheFactory implements FactoryInterface
&& isset($config['cache']['adapter'])
&& in_array($config['cache']['adapter'], self::VALID_CACHE_ADAPTERS)
) {
return new $config['cache']['adapter']();
return $this->resolveCacheAdapter($config['cache']);
}
// If the adapter has not been set in config, create one based on environment
return env('APP_ENV', 'pro') === 'pro' ? new ApcuCache() : new ArrayCache();
return env('APP_ENV', 'pro') === 'pro' ? new Cache\ApcuCache() : new Cache\ArrayCache();
}
/**
* @param array $cacheConfig
* @return Cache\Cache
*/
protected function resolveCacheAdapter(array $cacheConfig)
{
switch ($cacheConfig['adapter']) {
case Cache\ArrayCache::class:
case Cache\ApcuCache::class:
return new $cacheConfig['adapter']();
case Cache\FilesystemCache::class:
case Cache\PhpFileCache::class:
return new $cacheConfig['adapter']($cacheConfig['options']['dir']);
case Cache\MemcachedCache::class:
$memcached = new \Memcached();
$servers = isset($cacheConfig['options']['servers']) ? $cacheConfig['options']['servers'] : [];
foreach ($servers as $server) {
if (! isset($server['host'])) {
continue;
}
$port = isset($server['port']) ? intval($server['port']) : 11211;
$memcached->addServer($server['host'], $port);
}
$cache = new Cache\MemcachedCache();
$cache->setMemcached($memcached);
return $cache;
default:
return new Cache\ArrayCache();
}
}
}

View file

@ -30,12 +30,14 @@ class EntityManagerFactory implements FactoryInterface
$globalConfig = $container->get('config');
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
$dbConfig = isset($globalConfig['database']) ? $globalConfig['database'] : [];
$emConfig = isset($globalConfig['entity_manager']) ? $globalConfig['entity_manager'] : [];
$connecitonConfig = isset($emConfig['connection']) ? $emConfig['connection'] : [];
$ormConfig = isset($emConfig['orm']) ? $emConfig['orm'] : [];
return EntityManager::create($dbConfig, Setup::createAnnotationMetadataConfiguration(
['module/Core/src/Entity'],
return EntityManager::create($connecitonConfig, Setup::createAnnotationMetadataConfiguration(
isset($ormConfig['entities_paths']) ? $ormConfig['entities_paths'] : [],
$isDevMode,
'data/proxies',
isset($ormConfig['proxies_dir']) ? $ormConfig['proxies_dir'] : null,
$cache,
false
));

View file

@ -0,0 +1,39 @@
<?php
namespace Shlinkio\Shlink\Common\Factory;
use Cascade\Cascade;
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 LoggerFactory 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->has('config') ? $container->get('config') : [];
Cascade::fileConfig(isset($config['logger']) ? $config['logger'] : ['loggers' => []]);
// Compose requested logger name
$loggerName = isset($options) & isset($options['logger_name']) ? $options['logger_name'] : 'Logger';
$nameParts = explode('_', $requestedName);
if (count($nameParts) > 1) {
$loggerName = $nameParts[1];
}
return Cascade::getLogger($loggerName);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Shlinkio\Shlink\Common\Response;
use Endroid\QrCode\QrCode;
use Psr\Http\Message\StreamInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
class QrCodeResponse extends Response
{
use Response\InjectContentTypeTrait;
public function __construct(QrCode $qrCode, $status = 200, array $headers = [])
{
parent::__construct(
$this->createBody($qrCode),
$status,
$this->injectContentType($qrCode->getContentType(), $headers)
);
}
/**
* Create the message body.
*
* @param QrCode $qrCode
* @return StreamInterface
*/
private function createBody(QrCode $qrCode)
{
$body = new Stream('php://temp', 'wb+');
$body->write($qrCode->get());
$body->rewind();
return $body;
}
}

View file

@ -4,6 +4,8 @@ namespace ShlinkioTest\Shlink\Common\Factory;
use Doctrine\Common\Cache\ApcuCache;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\FilesystemCache;
use Doctrine\Common\Cache\MemcachedCache;
use Doctrine\Common\Cache\RedisCache;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Zend\ServiceManager\ServiceManager;
@ -61,15 +63,51 @@ class CacheFactoryTest extends TestCase
public function invalidAdapterDefinedInConfigFallbacksToEnvironment()
{
putenv('APP_ENV=pro');
$instance = $this->factory->__invoke($this->createSM(FilesystemCache::class), '');
$instance = $this->factory->__invoke($this->createSM(RedisCache::class), '');
$this->assertInstanceOf(ApcuCache::class, $instance);
}
private function createSM($cacheAdapter = null)
/**
* @test
*/
public function filesystemCacheAdaptersReadDirOption()
{
$dir = sys_get_temp_dir();
/** @var FilesystemCache $instance */
$instance = $this->factory->__invoke($this->createSM(FilesystemCache::class, ['dir' => $dir]), '');
$this->assertInstanceOf(FilesystemCache::class, $instance);
$this->assertEquals($dir, $instance->getDirectory());
}
/**
* @test
*/
public function memcachedCacheAdaptersReadServersOption()
{
$servers = [
[
'host' => '1.2.3.4',
'port' => 123
],
[
'host' => '4.3.2.1',
'port' => 321
],
];
/** @var MemcachedCache $instance */
$instance = $this->factory->__invoke($this->createSM(MemcachedCache::class, ['servers' => $servers]), '');
$this->assertInstanceOf(MemcachedCache::class, $instance);
$this->assertEquals(count($servers), count($instance->getMemcached()->getServerList()));
}
private function createSM($cacheAdapter = null, array $options = [])
{
return new ServiceManager(['services' => [
'config' => isset($cacheAdapter) ? [
'cache' => ['adapter' => $cacheAdapter],
'cache' => [
'adapter' => $cacheAdapter,
'options' => $options,
],
] : [],
]]);
}

View file

@ -26,8 +26,10 @@ class EntityManagerFactoryTest extends TestCase
$sm = new ServiceManager(['services' => [
'config' => [
'debug' => true,
'database' => [
'driver' => 'pdo_sqlite',
'entity_manager' => [
'connection' => [
'driver' => 'pdo_sqlite',
],
],
],
]]);

View file

@ -0,0 +1,54 @@
<?php
namespace ShlinkioTest\Shlink\Common\Factory;
use Monolog\Logger;
use PHPUnit_Framework_TestCase as TestCase;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Factory\LoggerFactory;
use Zend\ServiceManager\ServiceManager;
class LoggerFactoryTest extends TestCase
{
/**
* @var LoggerFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new LoggerFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
/** @var Logger $instance */
$instance = $this->factory->__invoke(new ServiceManager(), '');
$this->assertInstanceOf(LoggerInterface::class, $instance);
$this->assertEquals('Logger', $instance->getName());
}
/**
* @test
*/
public function nameIsSetFromOptions()
{
/** @var Logger $instance */
$instance = $this->factory->__invoke(new ServiceManager(), '', ['logger_name' => 'Foo']);
$this->assertInstanceOf(LoggerInterface::class, $instance);
$this->assertEquals('Foo', $instance->getName());
}
/**
* @test
*/
public function serviceNameOverwritesOptionsLoggerName()
{
/** @var Logger $instance */
$instance = $this->factory->__invoke(new ServiceManager(), 'Logger_Shlink', ['logger_name' => 'Foo']);
$this->assertInstanceOf(LoggerInterface::class, $instance);
$this->assertEquals('Shlink', $instance->getName());
}
}

View file

@ -0,0 +1,6 @@
<?php
return [
'app_options' => [],
];

View file

@ -1,12 +1,16 @@
<?php
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Action;
use Shlinkio\Shlink\Core\Middleware;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service;
return [
'dependencies' => [
'factories' => [
AppOptions::class => AnnotatedFactory::class,
// Services
Service\UrlShortener::class => AnnotatedFactory::class,
Service\VisitsTracker::class => AnnotatedFactory::class,
@ -14,7 +18,9 @@ return [
Service\VisitService::class => AnnotatedFactory::class,
// Middleware
RedirectAction::class => AnnotatedFactory::class,
Action\RedirectAction::class => AnnotatedFactory::class,
Action\QrCodeAction::class => AnnotatedFactory::class,
Middleware\QrCodeCacheMiddleware::class => AnnotatedFactory::class,
],
],

View file

@ -0,0 +1,12 @@
<?php
return [
'entity_manager' => [
'orm' => [
'entities_paths' => [
__DIR__ . '/../src/Entity',
],
],
],
];

View file

@ -1,5 +1,6 @@
<?php
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Action;
use Shlinkio\Shlink\Core\Middleware;
return [
@ -7,7 +8,16 @@ return [
[
'name' => 'long-url-redirect',
'path' => '/{shortCode}',
'middleware' => RedirectAction::class,
'middleware' => Action\RedirectAction::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'short-url-qr-code',
'path' => '/qr/{shortCode}[/{size:[0-9]+}]',
'middleware' => [
Middleware\QrCodeCacheMiddleware::class,
Action\QrCodeAction::class,
],
'allowed_methods' => ['GET'],
],
],

View file

@ -0,0 +1,113 @@
<?php
namespace Shlinkio\Shlink\Core\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Endroid\QrCode\QrCode;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Expressive\Router\RouterInterface;
use Zend\Stratigility\MiddlewareInterface;
class QrCodeAction implements MiddlewareInterface
{
/**
* @var RouterInterface
*/
private $router;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var LoggerInterface
*/
private $logger;
/**
* QrCodeAction constructor.
* @param RouterInterface $router
* @param UrlShortenerInterface $urlShortener
* @param LoggerInterface $logger
*
* @Inject({RouterInterface::class, UrlShortener::class, "Logger_Shlink"})
*/
public function __construct(
RouterInterface $router,
UrlShortenerInterface $urlShortener,
LoggerInterface $logger = null
) {
$this->router = $router;
$this->urlShortener = $urlShortener;
$this->logger = $logger ?: new NullLogger();
}
/**
* 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)
{
// Make sure the short URL exists for this short code
$shortCode = $request->getAttribute('shortCode');
try {
$shortUrl = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($shortUrl)) {
return $out($request, $response->withStatus(404), 'Not Found');
}
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e);
return $out($request, $response->withStatus(404), 'Not Found');
}
$path = $this->router->generateUri('long-url-redirect', ['shortCode' => $shortCode]);
$size = $this->getSizeParam($request);
$qrCode = new QrCode($request->getUri()->withPath($path)->withQuery(''));
$qrCode->setSize($size)
->setPadding(0);
return new QrCodeResponse($qrCode);
}
/**
* @param Request $request
* @return int
*/
protected function getSizeParam(Request $request)
{
$size = intval($request->getAttribute('size', 300));
if ($size < 50) {
return 50;
} elseif ($size > 1000) {
return 1000;
}
return $size;
}
}

View file

@ -4,6 +4,8 @@ namespace Shlinkio\Shlink\Core\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
@ -18,21 +20,30 @@ class RedirectAction implements MiddlewareInterface
*/
private $urlShortener;
/**
* @var VisitsTracker|VisitsTrackerInterface
* @var VisitsTrackerInterface
*/
private $visitTracker;
/**
* @var null|LoggerInterface
*/
private $logger;
/**
* RedirectMiddleware constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param VisitsTrackerInterface|VisitsTracker $visitTracker
* @param UrlShortenerInterface $urlShortener
* @param VisitsTrackerInterface $visitTracker
* @param LoggerInterface|null $logger
*
* @Inject({UrlShortener::class, VisitsTracker::class})
* @Inject({UrlShortener::class, VisitsTracker::class, "Logger_Shlink"})
*/
public function __construct(UrlShortenerInterface $urlShortener, VisitsTrackerInterface $visitTracker)
{
public function __construct(
UrlShortenerInterface $urlShortener,
VisitsTrackerInterface $visitTracker,
LoggerInterface $logger = null
) {
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;
$this->logger = $logger ?: new NullLogger();
}
/**
@ -74,13 +85,14 @@ class RedirectAction implements MiddlewareInterface
}
// Track visit to this short code
$this->visitTracker->track($shortCode);
$this->visitTracker->track($shortCode, $request);
// Return a redirect response to the long URL.
// Use a temporary redirect to make sure browsers always hit the server for analytics purposes
return new RedirectResponse($longUrl);
} catch (\Exception $e) {
// In case of error, dispatch 404 error
$this->logger->error('Error redirecting to long URL.' . PHP_EOL . $e);
return $this->notFoundResponse($request, $response, $out);
}
}

View file

@ -1,103 +0,0 @@
<?php
namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
/**
* Class RestToken
* @author
* @link
*
* @ORM\Entity()
* @ORM\Table(name="rest_tokens")
*/
class RestToken extends AbstractEntity
{
use StringUtilsTrait;
/**
* The default interval is 20 minutes
*/
const DEFAULT_INTERVAL = 'PT20M';
/**
* @var \DateTime
* @ORM\Column(type="datetime", name="expiration_date", nullable=false)
*/
protected $expirationDate;
/**
* @var string
* @ORM\Column(nullable=false)
*/
protected $token;
public function __construct()
{
$this->updateExpiration();
$this->setRandomTokenKey();
}
/**
* @return \DateTime
*/
public function getExpirationDate()
{
return $this->expirationDate;
}
/**
* @param \DateTime $expirationDate
* @return $this
*/
public function setExpirationDate($expirationDate)
{
$this->expirationDate = $expirationDate;
return $this;
}
/**
* @return string
*/
public function getToken()
{
return $this->token;
}
/**
* @param string $token
* @return $this
*/
public function setToken($token)
{
$this->token = $token;
return $this;
}
/**
* @return bool
*/
public function isExpired()
{
return new \DateTime() > $this->expirationDate;
}
/**
* Updates the expiration of the token, setting it to the default interval in the future
* @return $this
*/
public function updateExpiration()
{
return $this->setExpirationDate((new \DateTime())->add(new \DateInterval(self::DEFAULT_INTERVAL)));
}
/**
* Sets a random unique token key for this RestToken
* @return RestToken
*/
public function setRandomTokenKey()
{
return $this->setToken($this->generateV4Uuid());
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace Shlinkio\Shlink\Core\Middleware;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\Common\Cache\Cache;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class QrCodeCacheMiddleware implements MiddlewareInterface
{
/**
* @var Cache
*/
private $cache;
/**
* QrCodeCacheMiddleware constructor.
* @param Cache $cache
*
* @Inject({Cache::class})
*/
public function __construct(Cache $cache)
{
$this->cache = $cache;
}
/**
* 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)
{
$cacheKey = $request->getUri()->getPath();
// If this QR code is already cached, just return it
if ($this->cache->contains($cacheKey)) {
$qrData = $this->cache->fetch($cacheKey);
$response->getBody()->write($qrData['body']);
return $response->withHeader('Content-Type', $qrData['content-type']);
}
// If not, call the next middleware and cache it
/** @var Response $resp */
$resp = $out($request, $response);
$this->cache->save($cacheKey, [
'body' => $resp->getBody()->__toString(),
'content-type' => $resp->getHeaderLine('Content-Type'),
]);
return $resp;
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace Shlinkio\Shlink\Core\Options;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Zend\Stdlib\AbstractOptions;
class AppOptions extends AbstractOptions
{
use StringUtilsTrait;
/**
* @var string
*/
protected $name = '';
/**
* @var string
*/
protected $version = '1.0';
/**
* @var string
*/
protected $secretKey = '';
/**
* AppOptions constructor.
* @param array|null|\Traversable $options
*
* @Inject({"config.app_options"})
*/
public function __construct($options = null)
{
parent::__construct($options);
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param string $name
* @return $this
*/
protected function setName($name)
{
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getVersion()
{
return $this->version;
}
/**
* @param string $version
* @return $this
*/
protected function setVersion($version)
{
$this->version = $version;
return $this;
}
/**
* @return mixed
*/
public function getSecretKey()
{
return $this->secretKey;
}
/**
* @param mixed $secretKey
* @return $this
*/
protected function setSecretKey($secretKey)
{
$this->secretKey = $secretKey;
return $this;
}
/**
* @return string
*/
public function __toString()
{
return sprintf('%s:v%s', $this->name, $this->version);
}
}

View file

@ -2,6 +2,7 @@
namespace Shlinkio\Shlink\Core\Service;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
use GuzzleHttp\ClientInterface;
@ -28,23 +29,30 @@ class UrlShortener implements UrlShortenerInterface
* @var string
*/
private $chars;
/**
* @var Cache
*/
private $cache;
/**
* UrlShortener constructor.
* @param ClientInterface $httpClient
* @param EntityManagerInterface $em
* @param Cache $cache
* @param string $chars
*
* @Inject({"httpClient", "em", "config.url_shortener.shortcode_chars"})
* @Inject({"httpClient", "em", Cache::class, "config.url_shortener.shortcode_chars"})
*/
public function __construct(
ClientInterface $httpClient,
EntityManagerInterface $em,
Cache $cache,
$chars = self::DEFAULT_CHARS
) {
$this->httpClient = $httpClient;
$this->em = $em;
$this->chars = empty($chars) ? self::DEFAULT_CHARS : $chars;
$this->cache = $cache;
}
/**
@ -91,7 +99,7 @@ class UrlShortener implements UrlShortenerInterface
$this->em->close();
}
throw new RuntimeException('An error occured while persisting the short URL', -1, $e);
throw new RuntimeException('An error occurred while persisting the short URL', -1, $e);
}
}
@ -140,6 +148,12 @@ class UrlShortener implements UrlShortenerInterface
*/
public function shortCodeToUrl($shortCode)
{
$cacheKey = sprintf('%s_longUrl', $shortCode);
// Check if the short code => URL map is already cached
if ($this->cache->contains($cacheKey)) {
return $this->cache->fetch($cacheKey);
}
// Validate short code format
if (! preg_match('|[' . $this->chars . "]+|", $shortCode)) {
throw InvalidShortCodeException::fromShortCode($shortCode, $this->chars);
@ -149,6 +163,13 @@ class UrlShortener implements UrlShortenerInterface
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
]);
return isset($shortUrl) ? $shortUrl->getOriginalUrl() : null;
// Cache the shortcode
if (isset($shortUrl)) {
$url = $shortUrl->getOriginalUrl();
$this->cache->save($cacheKey, $url);
return $url;
}
return null;
}
}

View file

@ -3,6 +3,7 @@ namespace Shlinkio\Shlink\Core\Service;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@ -31,12 +32,10 @@ class VisitsTracker implements VisitsTrackerInterface
* Tracks a new visit to provided short code, using an array of data to look up information
*
* @param string $shortCode
* @param array $visitorData Defaults to global $_SERVER
* @param ServerRequestInterface $request
*/
public function track($shortCode, array $visitorData = null)
public function track($shortCode, ServerRequestInterface $request)
{
$visitorData = $visitorData ?: $_SERVER;
/** @var ShortUrl $shortUrl */
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
'shortCode' => $shortCode,
@ -44,22 +43,27 @@ class VisitsTracker implements VisitsTrackerInterface
$visit = new Visit();
$visit->setShortUrl($shortUrl)
->setUserAgent($this->getArrayValue($visitorData, 'HTTP_USER_AGENT'))
->setReferer($this->getArrayValue($visitorData, 'HTTP_REFERER'))
->setRemoteAddr($this->getArrayValue($visitorData, 'REMOTE_ADDR'));
->setUserAgent($request->getHeaderLine('User-Agent'))
->setReferer($request->getHeaderLine('Referer'))
->setRemoteAddr($this->findOutRemoteAddr($request));
$this->em->persist($visit);
$this->em->flush();
}
/**
* @param array $array
* @param $key
* @param null $default
* @return mixed|null
* @param ServerRequestInterface $request
* @return string
*/
protected function getArrayValue(array $array, $key, $default = null)
protected function findOutRemoteAddr(ServerRequestInterface $request)
{
return isset($array[$key]) ? $array[$key] : $default;
$forwardedFor = $request->getHeaderLine('X-Forwarded-For');
if (empty($forwardedFor)) {
$serverParams = $request->getServerParams();
return isset($serverParams['REMOTE_ADDR']) ? $serverParams['REMOTE_ADDR'] : null;
}
$ips = explode(',', $forwardedFor);
return $ips[0];
}
/**

View file

@ -1,6 +1,7 @@
<?php
namespace Shlinkio\Shlink\Core\Service;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
@ -10,9 +11,10 @@ interface VisitsTrackerInterface
* Tracks a new visit to provided short code, using an array of data to look up information
*
* @param string $shortCode
* @param array $visitorData Defaults to global $_SERVER
* @param ServerRequestInterface $request
* @return
*/
public function track($shortCode, array $visitorData = null);
public function track($shortCode, ServerRequestInterface $request);
/**
* Returns the visits on certain short code

View file

@ -27,7 +27,7 @@
<hr />
{% block footer %}
<p>
&copy; {{ "now" | date("Y") }} by <a href="http://www.alejandrocelaya.com">Alejandro Celaya</a>.
&copy; {{ "now" | date("Y") }} <a href="https://shlink.io">Shlink</a>
</p>
{% endblock %}
</div>

View file

@ -0,0 +1,93 @@
<?php
namespace ShlinkioTest\Shlink\Core\Action;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Expressive\Router\RouterInterface;
class QrCodeActionTest extends TestCase
{
/**
* @var QrCodeAction
*/
protected $action;
/**
* @var ObjectProphecy
*/
protected $urlShortener;
public function setUp()
{
$router = $this->prophesize(RouterInterface::class);
$router->generateUri(Argument::cetera())->willReturn('/foo/bar');
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->action = new QrCodeAction($router->reveal(), $this->urlShortener->reveal());
}
/**
* @test
*/
public function aNonexistentShortCodeWillReturnNotFoundResponse()
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(null)->shouldBeCalledTimes(1);
$resp = $this->action->__invoke(
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
new Response(),
function ($req, $resp) {
return $resp;
}
);
$this->assertEquals(404, $resp->getStatusCode());
}
/**
* @test
*/
public function anInvalidShortCodeWillReturnNotFoundResponse()
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
->shouldBeCalledTimes(1);
$resp = $this->action->__invoke(
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
new Response(),
function ($req, $resp) {
return $resp;
}
);
$this->assertEquals(404, $resp->getStatusCode());
}
/**
* @test
*/
public function aCorrectRequestReturnsTheQrCodeResponse()
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willReturn(new ShortUrl())->shouldBeCalledTimes(1);
$resp = $this->action->__invoke(
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
new Response(),
function ($req, $resp) {
return $resp;
}
);
$this->assertInstanceOf(QrCodeResponse::class, $resp);
$this->assertEquals(200, $resp->getStatusCode());
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace ShlinkioTest\Shlink\Core\Middleware;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Core\Middleware\QrCodeCacheMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Uri;
class QrCodeCacheMiddlewareTest extends TestCase
{
/**
* @var QrCodeCacheMiddleware
*/
protected $middleware;
/**
* @var Cache
*/
protected $cache;
public function setUp()
{
$this->cache = new ArrayCache();
$this->middleware = new QrCodeCacheMiddleware($this->cache);
}
/**
* @test
*/
public function noCachedPathFallbacksToNextMiddleware()
{
$isCalled = false;
$this->middleware->__invoke(
ServerRequestFactory::fromGlobals(),
new Response(),
function ($req, $resp) use (&$isCalled) {
$isCalled = true;
return $resp;
}
);
$this->assertTrue($isCalled);
}
/**
* @test
*/
public function cachedPathReturnsCacheContent()
{
$isCalled = false;
$uri = (new Uri())->withPath('/foo');
$this->cache->save('/foo', ['body' => 'the body', 'content-type' => 'image/png']);
$resp = $this->middleware->__invoke(
ServerRequestFactory::fromGlobals()->withUri($uri),
new Response(),
function ($req, $resp) use (&$isCalled) {
$isCalled = true;
return $resp;
}
);
$this->assertFalse($isCalled);
$resp->getBody()->rewind();
$this->assertEquals('the body', $resp->getBody()->getContents());
$this->assertEquals('image/png', $resp->getHeaderLine('Content-Type'));
}
}

View file

@ -1,6 +1,8 @@
<?php
namespace ShlinkioTest\Shlink\Core\Service;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
use Doctrine\Common\Persistence\ObjectRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
@ -29,6 +31,10 @@ class UrlShortenerTest extends TestCase
* @var ObjectProphecy
*/
protected $httpClient;
/**
* @var Cache
*/
protected $cache;
public function setUp()
{
@ -50,7 +56,9 @@ class UrlShortenerTest extends TestCase
$repo->findOneBy(Argument::any())->willReturn(null);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal());
$this->cache = new ArrayCache();
$this->urlShortener = new UrlShortener($this->httpClient->reveal(), $this->em->reveal(), $this->cache);
}
/**
@ -112,16 +120,19 @@ class UrlShortenerTest extends TestCase
public function shortCodeIsProperlyParsed()
{
// 12C1c -> 10
$shortCode = '12C1c';
$shortUrl = new ShortUrl();
$shortUrl->setShortCode('12C1c')
$shortUrl->setShortCode($shortCode)
->setOriginalUrl('expected_url');
$repo = $this->prophesize(ObjectRepository::class);
$repo->findOneBy(['shortCode' => '12C1c'])->willReturn($shortUrl);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$url = $this->urlShortener->shortCodeToUrl('12C1c');
$this->assertFalse($this->cache->contains($shortCode . '_longUrl'));
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$this->assertEquals($shortUrl->getOriginalUrl(), $url);
$this->assertTrue($this->cache->contains($shortCode . '_longUrl'));
}
/**
@ -132,4 +143,18 @@ class UrlShortenerTest extends TestCase
{
$this->urlShortener->shortCodeToUrl('&/(');
}
/**
* @test
*/
public function cachedShortCodeDoesNotHitDatabase()
{
$shortCode = '12C1c';
$expectedUrl = 'expected_url';
$this->cache->save($shortCode . '_longUrl', $expectedUrl);
$this->em->getRepository(ShortUrl::class)->willReturn(null)->shouldBeCalledTimes(0);
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$this->assertEquals($expectedUrl, $url);
}
}

View file

@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Zend\Diactoros\ServerRequestFactory;
class VisitsTrackerTest extends TestCase
{
@ -41,7 +42,30 @@ class VisitsTrackerTest extends TestCase
$this->em->persist(Argument::any())->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledTimes(1);
$this->visitsTracker->track($shortCode);
$this->visitsTracker->track($shortCode, ServerRequestFactory::fromGlobals());
}
/**
* @test
*/
public function trackUsesForwardedForHeaderIfPresent()
{
$shortCode = '123ABC';
$test = $this;
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl());
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$this->em->persist(Argument::any())->will(function ($args) use ($test) {
/** @var Visit $visit */
$visit = $args[0];
$test->assertEquals('4.3.2.1', $visit->getRemoteAddr());
})->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledTimes(1);
$this->visitsTracker->track($shortCode, ServerRequestFactory::fromGlobals(
['REMOTE_ADDR' => '1.2.3.4']
)->withHeader('X-Forwarded-For', '4.3.2.1,99.99.99.99'));
}
/**

View file

@ -1,6 +1,7 @@
<?php
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Shlinkio\Shlink\Rest\Action;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Middleware;
use Shlinkio\Shlink\Rest\Service;
use Zend\ServiceManager\Factory\InvokableFactory;
@ -9,7 +10,8 @@ return [
'dependencies' => [
'factories' => [
Service\RestTokenService::class => AnnotatedFactory::class,
JWTService::class => AnnotatedFactory::class,
Service\ApiKeyService::class => AnnotatedFactory::class,
Action\AuthenticateAction::class => AnnotatedFactory::class,
Action\CreateShortcodeAction::class => AnnotatedFactory::class,

View file

@ -0,0 +1,12 @@
<?php
return [
'entity_manager' => [
'orm' => [
'entities_paths' => [
__DIR__ . '/../src/Entity',
],
],
],
];

Binary file not shown.

View file

@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2016-07-27 08:53+0200\n"
"PO-Revision-Date: 2016-07-27 08:53+0200\n"
"POT-Creation-Date: 2016-08-07 20:19+0200\n"
"PO-Revision-Date: 2016-08-07 20:21+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@ -17,11 +17,13 @@ msgstr ""
"X-Poedit-SearchPath-0: config\n"
"X-Poedit-SearchPath-1: src\n"
msgid "You have to provide both \"username\" and \"password\""
msgstr "Debes proporcionar tanto \"username\" como \"password\""
msgid "You have to provide a valid API key under the \"apiKey\" param name."
msgstr ""
"Debes proporcionar una clave de API válida bajo el nombre de parámetro "
"\"apiKey\"."
msgid "Invalid username and/or password"
msgstr "Usuario y/o contraseña no válidos"
msgid "Provided API key does not exist or is invalid."
msgstr "La clave de API proporcionada no existe o es inválida."
msgid "A URL was not provided"
msgstr "No se ha proporcionado una URL"
@ -47,6 +49,16 @@ msgstr "No se ha encontrado una URL para el código corto \"%s\""
msgid "Provided short code \"%s\" has an invalid format"
msgstr "El código corto proporcionado \"%s\" tiene un formato no inválido"
#, php-format
msgid "You need to provide the Bearer type in the %s header."
msgstr "Debes proporcionar el typo Bearer en la cabecera %s."
#, php-format
msgid "Provided authorization type %s is not supported. Use Bearer instead."
msgstr ""
"El tipo de autorización proporcionado %s no está soportado. En vez de eso "
"utiliza Bearer."
#, php-format
msgid ""
"Missing or invalid auth token provided. Perform a new authentication request "
@ -56,8 +68,14 @@ msgstr ""
"una nueva petición de autenticación y envía el token proporcionado en cada "
"nueva petición en la cabecera \"%s\""
msgid "Requested route does not exist."
msgstr "La ruta solicitada no existe."
#~ msgid "You have to provide both \"username\" and \"password\""
#~ msgstr "Debes proporcionar tanto \"username\" como \"password\""
#~ msgid "Invalid username and/or password"
#~ msgstr "Usuario y/o contraseña no válidos"
#~ msgid "Requested route does not exist."
#~ msgstr "La ruta solicitada no existe."
#~ msgid "RestToken not found for token \"%s\""
#~ msgstr "No se ha encontrado un RestToken para el token \"%s\""

View file

@ -3,10 +3,22 @@ namespace Shlinkio\Shlink\Rest\Action;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Zend\Stratigility\MiddlewareInterface;
abstract class AbstractRestAction implements MiddlewareInterface
{
/**
* @var LoggerInterface
*/
protected $logger;
public function __construct(LoggerInterface $logger = null)
{
$this->logger = $logger ?: new NullLogger();
}
/**
* Process an incoming request and/or response.
*

View file

@ -2,37 +2,48 @@
namespace Shlinkio\Shlink\Rest\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Firebase\JWT\JWT;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
use Shlinkio\Shlink\Rest\Service\RestTokenService;
use Shlinkio\Shlink\Rest\Service\RestTokenServiceInterface;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class AuthenticateAction extends AbstractRestAction
{
/**
* @var RestTokenServiceInterface
*/
private $restTokenService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* @var ApiKeyService|ApiKeyServiceInterface
*/
private $apiKeyService;
/**
* @var JWTServiceInterface
*/
private $jwtService;
/**
* AuthenticateAction constructor.
* @param RestTokenServiceInterface|RestTokenService $restTokenService
* @param ApiKeyServiceInterface|ApiKeyService $apiKeyService
* @param JWTServiceInterface|JWTService $jwtService
* @param TranslatorInterface $translator
*
* @Inject({RestTokenService::class, "translator"})
* @Inject({ApiKeyService::class, JWTService::class, "translator"})
*/
public function __construct(RestTokenServiceInterface $restTokenService, TranslatorInterface $translator)
{
$this->restTokenService = $restTokenService;
public function __construct(
ApiKeyServiceInterface $apiKeyService,
JWTServiceInterface $jwtService,
TranslatorInterface $translator
) {
$this->translator = $translator;
$this->apiKeyService = $apiKeyService;
$this->jwtService = $jwtService;
}
/**
@ -44,21 +55,26 @@ class AuthenticateAction extends AbstractRestAction
public function dispatch(Request $request, Response $response, callable $out = null)
{
$authData = $request->getParsedBody();
if (! isset($authData['username'], $authData['password'])) {
if (! isset($authData['apiKey'])) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => $this->translator->translate('You have to provide both "username" and "password"'),
'message' => $this->translator->translate(
'You have to provide a valid API key under the "apiKey" param name.'
),
], 400);
}
try {
$token = $this->restTokenService->createToken($authData['username'], $authData['password']);
return new JsonResponse(['token' => $token->getToken()]);
} catch (AuthenticationException $e) {
// Authenticate using provided API key
$apiKey = $this->apiKeyService->getByKey($authData['apiKey']);
if (! $apiKey->isValid()) {
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => $this->translator->translate('Invalid username and/or password'),
'error' => RestUtils::INVALID_API_KEY_ERROR,
'message' => $this->translator->translate('Provided API key does not exist or is invalid.'),
], 401);
}
// Generate a JSON Web Token that will be used for authorization in next requests
$token = $this->jwtService->create($apiKey);
return new JsonResponse(['token' => $token]);
}
}

View file

@ -4,6 +4,7 @@ namespace Shlinkio\Shlink\Rest\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@ -33,14 +34,17 @@ class CreateShortcodeAction extends AbstractRestAction
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param TranslatorInterface $translator
* @param array $domainConfig
* @param LoggerInterface|null $logger
*
* @Inject({UrlShortener::class, "translator", "config.url_shortener.domain"})
* @Inject({UrlShortener::class, "translator", "config.url_shortener.domain", "Logger_Shlink"})
*/
public function __construct(
UrlShortenerInterface $urlShortener,
TranslatorInterface $translator,
array $domainConfig
array $domainConfig,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->urlShortener = $urlShortener;
$this->translator = $translator;
$this->domainConfig = $domainConfig;
@ -75,14 +79,16 @@ class CreateShortcodeAction extends AbstractRestAction
'shortCode' => $shortCode,
]);
} catch (InvalidUrlException $e) {
$this->logger->warning('Provided Invalid URL.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf(
$this->translator->translate('Provided URL "%s" is invalid. Try with a different one.'),
$this->translator->translate('Provided URL %s is invalid. Try with a different one.'),
$longUrl
),
], 400);
} catch (\Exception $e) {
$this->logger->error('Unexpected error creating shortcode.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => $this->translator->translate('Unexpected error occurred'),

View file

@ -4,6 +4,7 @@ namespace Shlinkio\Shlink\Rest\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
@ -25,13 +26,18 @@ class GetVisitsAction extends AbstractRestAction
/**
* GetVisitsAction constructor.
* @param VisitsTrackerInterface|VisitsTracker $visitsTracker
* @param VisitsTrackerInterface $visitsTracker
* @param TranslatorInterface $translator
* @param LoggerInterface $logger
*
* @Inject({VisitsTracker::class, "translator"})
* @Inject({VisitsTracker::class, "translator", "Logger_Shlink"})
*/
public function __construct(VisitsTrackerInterface $visitsTracker, TranslatorInterface $translator)
{
public function __construct(
VisitsTrackerInterface $visitsTracker,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->visitsTracker = $visitsTracker;
$this->translator = $translator;
}
@ -57,11 +63,16 @@ class GetVisitsAction extends AbstractRestAction
]
]);
} catch (InvalidArgumentException $e) {
$this->logger->warning('Provided nonexistent shortcode'. PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf($this->translator->translate('Provided short code "%s" is invalid'), $shortCode),
], 400);
'message' => sprintf(
$this->translator->translate('Provided short code %s does not exist'),
$shortCode
),
], 404);
} catch (\Exception $e) {
$this->logger->error('Unexpected error while parsing short code'. PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => $this->translator->translate('Unexpected error occurred'),

View file

@ -4,6 +4,8 @@ namespace Shlinkio\Shlink\Rest\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@ -28,11 +30,16 @@ class ListShortcodesAction extends AbstractRestAction
* ListShortcodesAction constructor.
* @param ShortUrlServiceInterface|ShortUrlService $shortUrlService
* @param TranslatorInterface $translator
* @param LoggerInterface $logger
*
* @Inject({ShortUrlService::class, "translator"})
* @Inject({ShortUrlService::class, "translator", "Logger_Shlink"})
*/
public function __construct(ShortUrlServiceInterface $shortUrlService, TranslatorInterface $translator)
{
public function __construct(
ShortUrlServiceInterface $shortUrlService,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->shortUrlService = $shortUrlService;
$this->translator = $translator;
}
@ -50,6 +57,7 @@ class ListShortcodesAction extends AbstractRestAction
$shortUrls = $this->shortUrlService->listShortUrls(isset($query['page']) ? $query['page'] : 1);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls)]);
} catch (\Exception $e) {
$this->logger->error('Unexpected error while listing short URLs.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => $this->translator->translate('Unexpected error occurred'),

View file

@ -4,6 +4,7 @@ namespace Shlinkio\Shlink\Rest\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@ -26,11 +27,16 @@ class ResolveUrlAction extends AbstractRestAction
* ResolveUrlAction constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param TranslatorInterface $translator
* @param LoggerInterface $logger
*
* @Inject({UrlShortener::class, "translator"})
*/
public function __construct(UrlShortenerInterface $urlShortener, TranslatorInterface $translator)
{
public function __construct(
UrlShortenerInterface $urlShortener,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->urlShortener = $urlShortener;
$this->translator = $translator;
}
@ -50,14 +56,15 @@ class ResolveUrlAction extends AbstractRestAction
if (! isset($longUrl)) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => sprintf($this->translator->translate('No URL found for shortcode "%s"'), $shortCode),
], 400);
'message' => sprintf($this->translator->translate('No URL found for short code "%s"'), $shortCode),
], 404);
}
return new JsonResponse([
'longUrl' => $longUrl,
]);
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Provided short code with invalid format.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf(
@ -66,6 +73,7 @@ class ResolveUrlAction extends AbstractRestAction
),
], 400);
} catch (\Exception $e) {
$this->logger->error('Unexpected error while resolving the URL behind a short code.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => $this->translator->translate('Unexpected error occurred'),

View file

@ -0,0 +1,113 @@
<?php
namespace Shlinkio\Shlink\Rest\Authentication;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Firebase\JWT\JWT;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
class JWTService implements JWTServiceInterface
{
/**
* @var AppOptions
*/
private $appOptions;
/**
* JWTService constructor.
* @param AppOptions $appOptions
*
* @Inject({AppOptions::class})
*/
public function __construct(AppOptions $appOptions)
{
$this->appOptions = $appOptions;
}
/**
* Creates a new JSON web token por provided API key
*
* @param ApiKey $apiKey
* @param int $lifetime
* @return string
*/
public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME)
{
$currentTimestamp = time();
return $this->encode([
'iss' => $this->appOptions->__toString(),
'iat' => $currentTimestamp,
'exp' => $currentTimestamp + $lifetime,
'sub' => 'auth',
'key' => $apiKey->getId(), // The ID is opaque. Returning the key would be insecure
]);
}
/**
* Refreshes a token and returns it with the new expiration
*
* @param string $jwt
* @param int $lifetime
* @return string
* @throws AuthenticationException If the token has expired
*/
public function refresh($jwt, $lifetime = self::DEFAULT_LIFETIME)
{
$payload = $this->getPayload($jwt);
$payload['exp'] = time() + $lifetime;
return $this->encode($payload);
}
/**
* Verifies that certain JWT is valid
*
* @param string $jwt
* @return bool
*/
public function verify($jwt)
{
try {
// If no exception is thrown while decoding the token, it is considered valid
$this->decode($jwt);
return true;
} catch (\UnexpectedValueException $e) {
return false;
}
}
/**
* Decodes certain token and returns the payload
*
* @param string $jwt
* @return array
* @throws AuthenticationException If the token has expired
*/
public function getPayload($jwt)
{
try {
return $this->decode($jwt);
} catch (\UnexpectedValueException $e) {
throw AuthenticationException::expiredJWT($e);
}
}
/**
* @param array $data
* @return string
*/
protected function encode(array $data)
{
return JWT::encode($data, $this->appOptions->getSecretKey(), self::DEFAULT_ENCRYPTION_ALG);
}
/**
* @param $jwt
* @return array
*/
protected function decode($jwt)
{
return (array) JWT::decode($jwt, $this->appOptions->getSecretKey(), [self::DEFAULT_ENCRYPTION_ALG]);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Shlinkio\Shlink\Rest\Authentication;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
interface JWTServiceInterface
{
const DEFAULT_LIFETIME = 604800; // 1 week
const DEFAULT_ENCRYPTION_ALG = 'HS256';
/**
* Creates a new JSON web token por provided API key
*
* @param ApiKey $apiKey
* @param int $lifetime
* @return string
*/
public function create(ApiKey $apiKey, $lifetime = self::DEFAULT_LIFETIME);
/**
* Refreshes a token and returns it with the new expiration
*
* @param string $jwt
* @param int $lifetime
* @return string
* @throws AuthenticationException If the token has expired
*/
public function refresh($jwt, $lifetime = self::DEFAULT_LIFETIME);
/**
* Verifies that certain JWT is valid
*
* @param string $jwt
* @return bool
*/
public function verify($jwt);
/**
* Decodes certain token and returns the payload
*
* @param string $jwt
* @return array
* @throws AuthenticationException If the token has expired
*/
public function getPayload($jwt);
}

View file

@ -0,0 +1,137 @@
<?php
namespace Shlinkio\Shlink\Rest\Entity;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
/**
* Class ApiKey
* @author Shlink
* @link http://shlink.io
*
* @ORM\Entity()
* @ORM\Table(name="api_keys")
*/
class ApiKey extends AbstractEntity
{
use StringUtilsTrait;
/**
* @var string
* @ORM\Column(name="`key`", nullable=false, unique=true)
*/
protected $key;
/**
* @var \DateTime
* @ORM\Column(name="expiration_date", nullable=true, type="datetime")
*/
protected $expirationDate;
/**
* @var bool
* @ORM\Column(type="boolean")
*/
protected $enabled;
public function __construct()
{
$this->enabled = true;
$this->key = $this->generateV4Uuid();
}
/**
* @return string
*/
public function getKey()
{
return $this->key;
}
/**
* @param string $key
* @return $this
*/
public function setKey($key)
{
$this->key = $key;
return $this;
}
/**
* @return \DateTime
*/
public function getExpirationDate()
{
return $this->expirationDate;
}
/**
* @param \DateTime $expirationDate
* @return $this
*/
public function setExpirationDate($expirationDate)
{
$this->expirationDate = $expirationDate;
return $this;
}
/**
* @return bool
*/
public function isExpired()
{
if (! isset($this->expirationDate)) {
return false;
}
return $this->expirationDate < new \DateTime();
}
/**
* @return boolean
*/
public function isEnabled()
{
return $this->enabled;
}
/**
* @param boolean $enabled
* @return $this
*/
public function setEnabled($enabled)
{
$this->enabled = $enabled;
return $this;
}
/**
* Disables this API key
*
* @return $this
*/
public function disable()
{
return $this->setEnabled(false);
}
/**
* Tells if this api key is enabled and not expired
*
* @return bool
*/
public function isValid()
{
return $this->isEnabled() && ! $this->isExpired();
}
/**
* The string repesentation of an API key is the key itself
*
* @return string
*/
public function __toString()
{
return $this->getKey();
}
}

View file

@ -9,4 +9,9 @@ class AuthenticationException extends \RuntimeException implements ExceptionInte
{
return new self(sprintf('Invalid credentials. Username -> "%s". Password -> "%s"', $username, $password));
}
public static function expiredJWT(\Exception $prev = null)
{
return new self('The token has expired.', -1, $prev);
}
}

View file

@ -4,9 +4,11 @@ namespace Shlinkio\Shlink\Rest\Middleware;
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;
use Shlinkio\Shlink\Rest\Service\RestTokenService;
use Shlinkio\Shlink\Rest\Service\RestTokenServiceInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Expressive\Router\RouteResult;
@ -15,28 +17,37 @@ use Zend\Stratigility\MiddlewareInterface;
class CheckAuthenticationMiddleware implements MiddlewareInterface
{
const AUTH_TOKEN_HEADER = 'X-Auth-Token';
const AUTHORIZATION_HEADER = 'Authorization';
/**
* @var RestTokenServiceInterface
*/
private $restTokenService;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* @var JWTServiceInterface
*/
private $jwtService;
/**
* @var LoggerInterface
*/
private $logger;
/**
* CheckAuthenticationMiddleware constructor.
* @param RestTokenServiceInterface|RestTokenService $restTokenService
* @param JWTServiceInterface|JWTService $jwtService
* @param TranslatorInterface $translator
* @param LoggerInterface $logger
*
* @Inject({RestTokenService::class, "translator"})
* @Inject({JWTService::class, "translator", "Logger_Shlink"})
*/
public function __construct(RestTokenServiceInterface $restTokenService, TranslatorInterface $translator)
{
$this->restTokenService = $restTokenService;
public function __construct(
JWTServiceInterface $jwtService,
TranslatorInterface $translator,
LoggerInterface $logger = null
) {
$this->translator = $translator;
$this->jwtService = $jwtService;
$this->logger = $logger ?: new NullLogger();
}
/**
@ -78,21 +89,47 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface
}
// Check that the auth header was provided, and that it belongs to a non-expired token
if (! $request->hasHeader(self::AUTH_TOKEN_HEADER)) {
if (! $request->hasHeader(self::AUTHORIZATION_HEADER)) {
return $this->createTokenErrorResponse();
}
$authToken = $request->getHeaderLine(self::AUTH_TOKEN_HEADER);
// Get token making sure the an authorization type is provided
$authToken = $request->getHeaderLine(self::AUTHORIZATION_HEADER);
$authTokenParts = explode(' ', $authToken);
if (count($authTokenParts) === 1) {
return new JsonResponse([
'error' => RestUtils::INVALID_AUTHORIZATION_ERROR,
'message' => sprintf($this->translator->translate(
'You need to provide the Bearer type in the %s header.'
), self::AUTHORIZATION_HEADER),
], 401);
}
// Make sure the authorization type is Bearer
list($authType, $jwt) = $authTokenParts;
if (strtolower($authType) !== 'bearer') {
return new JsonResponse([
'error' => RestUtils::INVALID_AUTHORIZATION_ERROR,
'message' => sprintf($this->translator->translate(
'Provided authorization type %s is not supported. Use Bearer instead.'
), $authType),
], 401);
}
try {
$restToken = $this->restTokenService->getByToken($authToken);
if ($restToken->isExpired()) {
if (! $this->jwtService->verify($jwt)) {
return $this->createTokenErrorResponse();
}
// Update the token expiration and continue to next middleware
$this->restTokenService->updateExpiration($restToken);
return $out($request, $response);
} catch (InvalidArgumentException $e) {
$jwt = $this->jwtService->refresh($jwt);
/** @var Response $response */
$response = $out($request, $response);
// Return the response with the updated token on it
return $response->withHeader(self::AUTHORIZATION_HEADER, 'Bearer ' . $jwt);
} catch (AuthenticationException $e) {
$this->logger->warning('Tried to access API with an invalid JWT.' . PHP_EOL . $e);
return $this->createTokenErrorResponse();
}
}
@ -106,7 +143,7 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface
'Missing or invalid auth token provided. Perform a new authentication request and send provided '
. 'token on every new request on the "%s" header'
),
self::AUTH_TOKEN_HEADER
self::AUTHORIZATION_HEADER
),
], 401);
}

View file

@ -0,0 +1,106 @@
<?php
namespace Shlinkio\Shlink\Rest\Service;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ApiKeyService implements ApiKeyServiceInterface
{
/**
* @var EntityManagerInterface
*/
private $em;
/**
* ApiKeyService constructor.
* @param EntityManagerInterface $em
*
* @Inject({"em"})
*/
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
/**
* Creates a new ApiKey with provided expiration date
*
* @param \DateTime $expirationDate
* @return ApiKey
*/
public function create(\DateTime $expirationDate = null)
{
$key = new ApiKey();
if (isset($expirationDate)) {
$key->setExpirationDate($expirationDate);
}
$this->em->persist($key);
$this->em->flush();
return $key;
}
/**
* Checks if provided key is a valid api key
*
* @param string $key
* @return bool
*/
public function check($key)
{
/** @var ApiKey $apiKey */
$apiKey = $this->getByKey($key);
if (! isset($apiKey)) {
return false;
}
return $apiKey->isValid();
}
/**
* Disables provided api key
*
* @param string $key
* @return ApiKey
*/
public function disable($key)
{
/** @var ApiKey $apiKey */
$apiKey = $this->getByKey($key);
if (! isset($apiKey)) {
throw new InvalidArgumentException(sprintf('API key "%s" does not exist and can\'t be disabled', $key));
}
$apiKey->disable();
$this->em->flush();
return $apiKey;
}
/**
* Lists all existing appi keys
*
* @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[]
*/
public function listKeys($enabledOnly = false)
{
$conditions = $enabledOnly ? ['enabled' => true] : [];
return $this->em->getRepository(ApiKey::class)->findBy($conditions);
}
/**
* Tries to find one API key by its key string
*
* @param string $key
* @return ApiKey|null
*/
public function getByKey($key)
{
return $this->em->getRepository(ApiKey::class)->findOneBy([
'key' => $key,
]);
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Shlinkio\Shlink\Rest\Service;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface ApiKeyServiceInterface
{
/**
* Creates a new ApiKey with provided expiration date
*
* @param \DateTime $expirationDate
* @return ApiKey
*/
public function create(\DateTime $expirationDate = null);
/**
* Checks if provided key is a valid api key
*
* @param string $key
* @return bool
*/
public function check($key);
/**
* Disables provided api key
*
* @param string $key
* @return ApiKey
*/
public function disable($key);
/**
* Lists all existing appi keys
*
* @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[]
*/
public function listKeys($enabledOnly = false);
/**
* Tries to find one API key by its key string
*
* @param string $key
* @return ApiKey|null
*/
public function getByKey($key);
}

View file

@ -1,98 +0,0 @@
<?php
namespace Shlinkio\Shlink\Rest\Service;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Entity\RestToken;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
class RestTokenService implements RestTokenServiceInterface
{
/**
* @var EntityManagerInterface
*/
private $em;
/**
* @var array
*/
private $restConfig;
/**
* ShortUrlService constructor.
* @param EntityManagerInterface $em
* @param array $restConfig
*
* @Inject({"em", "config.rest"})
*/
public function __construct(EntityManagerInterface $em, array $restConfig)
{
$this->em = $em;
$this->restConfig = $restConfig;
}
/**
* @param string $token
* @return RestToken
* @throws InvalidArgumentException
*/
public function getByToken($token)
{
$restToken = $this->em->getRepository(RestToken::class)->findOneBy([
'token' => $token,
]);
if (! isset($restToken)) {
throw new InvalidArgumentException(sprintf('RestToken not found for token "%s"', $token));
}
return $restToken;
}
/**
* Creates and returns a new RestToken if username and password are correct
* @param $username
* @param $password
* @return RestToken
* @throws AuthenticationException
*/
public function createToken($username, $password)
{
$this->processCredentials($username, $password);
$restToken = new RestToken();
$this->em->persist($restToken);
$this->em->flush();
return $restToken;
}
/**
* @param string $username
* @param string $password
*/
protected function processCredentials($username, $password)
{
$configUsername = strtolower(trim($this->restConfig['username']));
$providedUsername = strtolower(trim($username));
$configPassword = trim($this->restConfig['password']);
$providedPassword = trim($password);
if ($configUsername === $providedUsername && $configPassword === $providedPassword) {
return;
}
// If credentials are not correct, throw exception
throw AuthenticationException::fromCredentials($providedUsername, $providedPassword);
}
/**
* Updates the expiration of provided token, extending its life
*
* @param RestToken $token
*/
public function updateExpiration(RestToken $token)
{
$token->updateExpiration();
$this->em->flush();
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace Shlinkio\Shlink\Rest\Service;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Entity\RestToken;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
interface RestTokenServiceInterface
{
/**
* @param string $token
* @return RestToken
* @throws InvalidArgumentException
*/
public function getByToken($token);
/**
* Creates and returns a new RestToken if username and password are correct
* @param $username
* @param $password
* @return RestToken
* @throws AuthenticationException
*/
public function createToken($username, $password);
/**
* Updates the expiration of provided token, extending its life
*
* @param RestToken $token
*/
public function updateExpiration(RestToken $token);
}

View file

@ -12,6 +12,8 @@ class RestUtils
const INVALID_ARGUMENT_ERROR = 'INVALID_ARGUMENT';
const INVALID_CREDENTIALS_ERROR = 'INVALID_CREDENTIALS';
const INVALID_AUTH_TOKEN_ERROR = 'INVALID_AUTH_TOKEN';
const INVALID_AUTHORIZATION_ERROR = 'INVALID_AUTHORIZATION';
const INVALID_API_KEY_ERROR = 'INVALID_API_KEY';
const NOT_FOUND_ERROR = 'NOT_FOUND';
const UNKNOWN_ERROR = 'UNKNOWN_ERROR';

View file

@ -3,10 +3,10 @@ namespace ShlinkioTest\Shlink\Rest\Action;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\RestToken;
use Shlinkio\Shlink\Rest\Action\AuthenticateAction;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
use Shlinkio\Shlink\Rest\Service\RestTokenService;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
@ -20,12 +20,21 @@ class AuthenticateActionTest extends TestCase
/**
* @var ObjectProphecy
*/
protected $tokenService;
protected $apiKeyService;
/**
* @var ObjectProphecy
*/
protected $jwtService;
public function setUp()
{
$this->tokenService = $this->prophesize(RestTokenService::class);
$this->action = new AuthenticateAction($this->tokenService->reveal(), Translator::factory([]));
$this->apiKeyService = $this->prophesize(ApiKeyService::class);
$this->jwtService = $this->prophesize(JWTService::class);
$this->action = new AuthenticateAction(
$this->apiKeyService->reveal(),
$this->jwtService->reveal(),
Translator::factory([])
);
}
/**
@ -40,34 +49,31 @@ class AuthenticateActionTest extends TestCase
/**
* @test
*/
public function properCredentialsReturnTokenInResponse()
public function properApiKeyReturnsTokenInResponse()
{
$this->tokenService->createToken('foo', 'bar')->willReturn(
(new RestToken())->setToken('abc-ABC')
)->shouldBeCalledTimes(1);
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setId(5))
->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'username' => 'foo',
'password' => 'bar',
'apiKey' => 'foo',
]);
$response = $this->action->__invoke($request, new Response());
$this->assertEquals(200, $response->getStatusCode());
$response->getBody()->rewind();
$this->assertEquals(['token' => 'abc-ABC'], json_decode($response->getBody()->getContents(), true));
$this->assertTrue(strpos($response->getBody()->getContents(), '"token"') > 0);
}
/**
* @test
*/
public function authenticationExceptionsReturnErrorResponse()
public function invalidApiKeyReturnsErrorResponse()
{
$this->tokenService->createToken('foo', 'bar')->willThrow(new AuthenticationException())
->shouldBeCalledTimes(1);
$this->apiKeyService->getByKey('foo')->willReturn((new ApiKey())->setEnabled(false))
->shouldBeCalledTimes(1);
$request = ServerRequestFactory::fromGlobals()->withParsedBody([
'username' => 'foo',
'password' => 'bar',
'apiKey' => 'foo',
]);
$response = $this->action->__invoke($request, new Response());
$this->assertEquals(401, $response->getStatusCode());

View file

@ -59,7 +59,7 @@ class GetVisitsActionTest extends TestCase
ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode),
new Response()
);
$this->assertEquals(400, $response->getStatusCode());
$this->assertEquals(404, $response->getStatusCode());
}
/**

View file

@ -39,7 +39,7 @@ class ResolveUrlActionTest extends TestCase
$request = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $shortCode);
$response = $this->action->__invoke($request, new Response());
$this->assertEquals(400, $response->getStatusCode());
$this->assertEquals(404, $response->getStatusCode());
$this->assertTrue(strpos($response->getBody()->getContents(), RestUtils::INVALID_ARGUMENT_ERROR) > 0);
}

View file

@ -0,0 +1,93 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Authentication;
use Firebase\JWT\JWT;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class JWTServiceTest extends TestCase
{
/**
* @var JWTService
*/
protected $service;
public function setUp()
{
$this->service = new JWTService(new AppOptions([
'name' => 'ShlinkTest',
'version' => '10000.3.1',
'secret_key' => 'foo',
]));
}
/**
* @test
*/
public function tokenIsProperlyCreated()
{
$id = 34;
$token = $this->service->create((new ApiKey())->setId($id));
$payload = (array) JWT::decode($token, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]);
$this->assertGreaterThanOrEqual($payload['iat'], time());
$this->assertGreaterThan(time(), $payload['exp']);
$this->assertEquals($id, $payload['key']);
$this->assertEquals('auth', $payload['sub']);
$this->assertEquals('ShlinkTest:v10000.3.1', $payload['iss']);
}
/**
* @test
*/
public function refreshIncreasesExpiration()
{
$originalLifetime = 10;
$newLifetime = 30;
$originalPayload = ['exp' => time() + $originalLifetime];
$token = JWT::encode($originalPayload, 'foo');
$newToken = $this->service->refresh($token, $newLifetime);
$newPayload = (array) JWT::decode($newToken, 'foo', [JWTService::DEFAULT_ENCRYPTION_ALG]);
$this->assertGreaterThan($originalPayload['exp'], $newPayload['exp']);
}
/**
* @test
*/
public function verifyReturnsTrueWhenTheTokenIsCorrect()
{
$this->assertTrue($this->service->verify(JWT::encode([], 'foo')));
}
/**
* @test
*/
public function verifyReturnsFalseWhenTheTokenIsCorrect()
{
$this->assertFalse($this->service->verify('invalidToken'));
}
/**
* @test
*/
public function getPayloadWorksWithCorrectTokens()
{
$originalPayload = [
'exp' => time() + 10,
'sub' => 'testing',
];
$token = JWT::encode($originalPayload, 'foo');
$this->assertEquals($originalPayload, $this->service->getPayload($token));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Rest\Exception\AuthenticationException
*/
public function getPayloadThrowsExceptionWithIncorrectTokens()
{
$this->service->getPayload('invalidToken');
}
}

View file

@ -3,9 +3,8 @@ namespace ShlinkioTest\Shlink\Rest\Middleware;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\RestToken;
use Shlinkio\Shlink\Rest\Authentication\JWTService;
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
use Shlinkio\Shlink\Rest\Service\RestTokenService;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Expressive\Router\RouteResult;
@ -20,18 +19,18 @@ class CheckAuthenticationMiddlewareTest extends TestCase
/**
* @var ObjectProphecy
*/
protected $tokenService;
protected $jwtService;
public function setUp()
{
$this->tokenService = $this->prophesize(RestTokenService::class);
$this->middleware = new CheckAuthenticationMiddleware($this->tokenService->reveal(), Translator::factory([]));
$this->jwtService = $this->prophesize(JWTService::class);
$this->middleware = new CheckAuthenticationMiddleware($this->jwtService->reveal(), Translator::factory([]));
}
/**
* @test
*/
public function someWhitelistedSituationsFallbackToNextMiddleware()
public function someWhiteListedSituationsFallbackToNextMiddleware()
{
$request = ServerRequestFactory::fromGlobals();
$response = new Response();
@ -92,6 +91,40 @@ class CheckAuthenticationMiddlewareTest extends TestCase
$this->assertEquals(401, $response->getStatusCode());
}
/**
* @test
*/
public function provideAnAuthorizationWithoutTypeReturnsError()
{
$authToken = 'ABC-abc';
$request = ServerRequestFactory::fromGlobals()->withAttribute(
RouteResult::class,
RouteResult::fromRouteMatch('bar', 'foo', [])
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, $authToken);
$response = $this->middleware->__invoke($request, new Response());
$this->assertEquals(401, $response->getStatusCode());
$this->assertTrue(strpos($response->getBody()->getContents(), 'You need to provide the Bearer type') > 0);
}
/**
* @test
*/
public function provideAnAuthorizationWithWrongTypeReturnsError()
{
$authToken = 'ABC-abc';
$request = ServerRequestFactory::fromGlobals()->withAttribute(
RouteResult::class,
RouteResult::fromRouteMatch('bar', 'foo', [])
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, 'Basic ' . $authToken);
$response = $this->middleware->__invoke($request, new Response());
$this->assertEquals(401, $response->getStatusCode());
$this->assertTrue(
strpos($response->getBody()->getContents(), 'Provided authorization type Basic is not supported') > 0
);
}
/**
* @test
*/
@ -101,10 +134,8 @@ class CheckAuthenticationMiddlewareTest extends TestCase
$request = ServerRequestFactory::fromGlobals()->withAttribute(
RouteResult::class,
RouteResult::fromRouteMatch('bar', 'foo', [])
)->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken);
$this->tokenService->getByToken($authToken)->willReturn(
(new RestToken())->setExpirationDate((new \DateTime())->sub(new \DateInterval('P1D')))
)->shouldBeCalledTimes(1);
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, 'Bearer ' . $authToken);
$this->jwtService->verify($authToken)->willReturn(false)->shouldBeCalledTimes(1);
$response = $this->middleware->__invoke($request, new Response());
$this->assertEquals(401, $response->getStatusCode());
@ -116,18 +147,18 @@ class CheckAuthenticationMiddlewareTest extends TestCase
public function provideCorrectTokenUpdatesExpirationAndFallbacksToNextMiddleware()
{
$authToken = 'ABC-abc';
$restToken = (new RestToken())->setExpirationDate((new \DateTime())->add(new \DateInterval('P1D')));
$request = ServerRequestFactory::fromGlobals()->withAttribute(
RouteResult::class,
RouteResult::fromRouteMatch('bar', 'foo', [])
)->withHeader(CheckAuthenticationMiddleware::AUTH_TOKEN_HEADER, $authToken);
$this->tokenService->getByToken($authToken)->willReturn($restToken)->shouldBeCalledTimes(1);
$this->tokenService->updateExpiration($restToken)->shouldBeCalledTimes(1);
)->withHeader(CheckAuthenticationMiddleware::AUTHORIZATION_HEADER, 'bearer ' . $authToken);
$this->jwtService->verify($authToken)->willReturn(true)->shouldBeCalledTimes(1);
$this->jwtService->refresh($authToken)->willReturn($authToken)->shouldBeCalledTimes(1);
$isCalled = false;
$this->assertFalse($isCalled);
$this->middleware->__invoke($request, new Response(), function ($req, $resp) use (&$isCalled) {
$isCalled = true;
return $resp;
});
$this->assertTrue($isCalled);
}

View file

@ -0,0 +1,168 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Service;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
class ApiKeyServiceTest extends TestCase
{
/**
* @var ApiKeyService
*/
protected $service;
/**
* @var ObjectProphecy
*/
protected $em;
public function setUp()
{
$this->em = $this->prophesize(EntityManager::class);
$this->service = new ApiKeyService($this->em->reveal());
}
/**
* @test
*/
public function keyIsProperlyCreated()
{
$this->em->flush()->shouldBeCalledTimes(1);
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledTimes(1);
$key = $this->service->create();
$this->assertNull($key->getExpirationDate());
}
/**
* @test
*/
public function keyIsProperlyCreatedWithExpirationDate()
{
$this->em->flush()->shouldBeCalledTimes(1);
$this->em->persist(Argument::type(ApiKey::class))->shouldBeCalledTimes(1);
$date = new \DateTime('2030-01-01');
$key = $this->service->create($date);
$this->assertSame($date, $key->getExpirationDate());
}
/**
* @test
*/
public function checkReturnsFalseWhenKeyIsInvalid()
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(null)
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
}
/**
* @test
*/
public function checkReturnsFalseWhenKeyIsDisabled()
{
$key = new ApiKey();
$key->disable();
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
}
/**
* @test
*/
public function checkReturnsFalseWhenKeyIsExpired()
{
$key = new ApiKey();
$key->setExpirationDate((new \DateTime())->sub(new \DateInterval('P1D')));
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertFalse($this->service->check('12345'));
}
/**
* @test
*/
public function checkReturnsTrueWhenConditionsAreFavorable()
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(new ApiKey())
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->assertTrue($this->service->check('12345'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException
*/
public function disableThrowsExceptionWhenNoTokenIsFound()
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn(null)
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->disable('12345');
}
/**
* @test
*/
public function disableReturnsDisabledKeyWhenFOund()
{
$key = new ApiKey();
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['key' => '12345'])->willReturn($key)
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->em->flush()->shouldBeCalledTimes(1);
$this->assertTrue($key->isEnabled());
$returnedKey = $this->service->disable('12345');
$this->assertFalse($key->isEnabled());
$this->assertSame($key, $returnedKey);
}
/**
* @test
*/
public function listFindsAllApiKeys()
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findBy([])->willReturn([])
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->listKeys();
}
/**
* @test
*/
public function listEnabledFindsOnlyEnabledApiKeys()
{
$repo = $this->prophesize(EntityRepository::class);
$repo->findBy(['enabled' => true])->willReturn([])
->shouldBeCalledTimes(1);
$this->em->getRepository(ApiKey::class)->willReturn($repo->reveal());
$this->service->listKeys(true);
}
}

View file

@ -1,93 +0,0 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Service;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityRepository;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\RestToken;
use Shlinkio\Shlink\Rest\Service\RestTokenService;
class RestTokenServiceTest extends TestCase
{
/**
* @var RestTokenService
*/
protected $service;
/**
* @var ObjectProphecy
*/
protected $em;
public function setUp()
{
$this->em = $this->prophesize(EntityManager::class);
$this->service = new RestTokenService($this->em->reveal(), [
'username' => 'foo',
'password' => 'bar',
]);
}
/**
* @test
*/
public function tokenIsCreatedIfCredentialsAreCorrect()
{
$this->em->persist(Argument::type(RestToken::class))->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledTimes(1);
$token = $this->service->createToken('foo', 'bar');
$this->assertInstanceOf(RestToken::class, $token);
$this->assertFalse($token->isExpired());
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Rest\Exception\AuthenticationException
*/
public function exceptionIsThrownWhileCreatingTokenWithWrongCredentials()
{
$this->service->createToken('foo', 'wrong');
}
/**
* @test
*/
public function restTokenIsReturnedFromTokenString()
{
$authToken = 'ABC-abc';
$theToken = new RestToken();
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['token' => $authToken])->willReturn($theToken)->shouldBeCalledTimes(1);
$this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$this->assertSame($theToken, $this->service->getByToken($authToken));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException
*/
public function exceptionIsThrownWhenRequestingWrongToken()
{
$authToken = 'ABC-abc';
$repo = $this->prophesize(EntityRepository::class);
$repo->findOneBy(['token' => $authToken])->willReturn(null)->shouldBeCalledTimes(1);
$this->em->getRepository(RestToken::class)->willReturn($repo->reveal())->shouldBeCalledTimes(1);
$this->service->getByToken($authToken);
}
/**
* @test
*/
public function updateExpirationFlushesEntityManager()
{
$token = $this->prophesize(RestToken::class);
$token->updateExpiration()->shouldBeCalledTimes(1);
$this->em->flush()->shouldBeCalledTimes(1);
$this->service->updateExpiration($token->reveal());
}
}