mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Merge branch 'develop'
This commit is contained in:
commit
ba5bd6d98c
87 changed files with 2452 additions and 835 deletions
|
@ -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
19
.phpstorm.meta.php
Normal 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
1
.travis-php.ini
Normal file
|
@ -0,0 +1 @@
|
|||
extension="memcached.so"
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
10
config/autoload/app_options.global.php
Normal file
10
config/autoload/app_options.global.php
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'app_options' => [
|
||||
'name' => 'Shlink',
|
||||
'version' => '1.1.0',
|
||||
'secret_key' => env('SECRET_KEY'),
|
||||
],
|
||||
|
||||
];
|
|
@ -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'
|
||||
],
|
||||
],
|
||||
|
||||
];
|
20
config/autoload/entity-manager.global.php
Normal file
20
config/autoload/entity-manager.global.php
Normal 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',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -1,7 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'debug' => true,
|
||||
|
||||
'debug' => true,
|
||||
'config_cache_enabled' => false,
|
||||
|
||||
];
|
||||
|
|
32
config/autoload/logger.global.php
Normal file
32
config/autoload/logger.global.php
Normal 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'],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
14
config/autoload/logger.local.php.dist
Normal file
14
config/autoload/logger.local.php.dist
Normal file
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
use Monolog\Logger;
|
||||
|
||||
return [
|
||||
|
||||
'logger' => [
|
||||
'handlers' => [
|
||||
'rotating_file_handler' => [
|
||||
'level' => Logger::DEBUG,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -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
|
|
@ -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
2
data/log/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -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,
|
||||
]
|
||||
],
|
||||
|
||||
|
|
|
@ -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.
|
@ -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\". "
|
||||
|
|
62
module/CLI/src/Command/Api/DisableKeyCommand.php
Normal file
62
module/CLI/src/Command/Api/DisableKeyCommand.php
Normal 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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
56
module/CLI/src/Command/Api/GenerateKeyCommand.php
Normal file
56
module/CLI/src/Command/Api/GenerateKeyCommand.php
Normal 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));
|
||||
}
|
||||
}
|
108
module/CLI/src/Command/Api/ListKeysCommand.php
Normal file
108
module/CLI/src/Command/Api/ListKeysCommand.php
Normal 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);
|
||||
}
|
||||
}
|
45
module/CLI/src/Command/Config/GenerateSecretCommand.php
Normal file
45
module/CLI/src/Command/Config/GenerateSecretCommand.php
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
*
|
|
@ -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"})
|
|
@ -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"})
|
|
@ -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"})
|
|
@ -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"})
|
62
module/CLI/test/Command/Api/DisableKeyCommandTest.php
Normal file
62
module/CLI/test/Command/Api/DisableKeyCommandTest.php
Normal 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);
|
||||
}
|
||||
}
|
55
module/CLI/test/Command/Api/GenerateKeyCommandTest.php
Normal file
55
module/CLI/test/Command/Api/GenerateKeyCommandTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
62
module/CLI/test/Command/Api/ListKeysCommandTest.php
Normal file
62
module/CLI/test/Command/Api/ListKeysCommandTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
));
|
||||
|
|
39
module/Common/src/Factory/LoggerFactory.php
Normal file
39
module/Common/src/Factory/LoggerFactory.php
Normal 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);
|
||||
}
|
||||
}
|
35
module/Common/src/Response/QrCodeResponse.php
Normal file
35
module/Common/src/Response/QrCodeResponse.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
],
|
||||
] : [],
|
||||
]]);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]);
|
||||
|
|
54
module/Common/test/Factory/LoggerFactoryTest.php
Normal file
54
module/Common/test/Factory/LoggerFactoryTest.php
Normal 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());
|
||||
}
|
||||
}
|
6
module/Core/config/app_options.config.php
Normal file
6
module/Core/config/app_options.config.php
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'app_options' => [],
|
||||
|
||||
];
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
12
module/Core/config/entity-manager.config.php
Normal file
12
module/Core/config/entity-manager.config.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
'orm' => [
|
||||
'entities_paths' => [
|
||||
__DIR__ . '/../src/Entity',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
|
@ -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'],
|
||||
],
|
||||
],
|
||||
|
|
113
module/Core/src/Action/QrCodeAction.php
Normal file
113
module/Core/src/Action/QrCodeAction.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
73
module/Core/src/Middleware/QrCodeCacheMiddleware.php
Normal file
73
module/Core/src/Middleware/QrCodeCacheMiddleware.php
Normal 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;
|
||||
}
|
||||
}
|
97
module/Core/src/Options/AppOptions.php
Normal file
97
module/Core/src/Options/AppOptions.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
<hr />
|
||||
{% block footer %}
|
||||
<p>
|
||||
© {{ "now" | date("Y") }} by <a href="http://www.alejandrocelaya.com">Alejandro Celaya</a>.
|
||||
© {{ "now" | date("Y") }} <a href="https://shlink.io">Shlink</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
|
93
module/Core/test/Action/QrCodeActionTest.php
Normal file
93
module/Core/test/Action/QrCodeActionTest.php
Normal 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());
|
||||
}
|
||||
}
|
69
module/Core/test/Middleware/QrCodeCacheMiddlewareTest.php
Normal file
69
module/Core/test/Middleware/QrCodeCacheMiddlewareTest.php
Normal 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'));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
|
|
12
module/Rest/config/entity-manager.config.php
Normal file
12
module/Rest/config/entity-manager.config.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
'orm' => [
|
||||
'entities_paths' => [
|
||||
__DIR__ . '/../src/Entity',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
Binary file not shown.
|
@ -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\""
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'),
|
||||
|
|
113
module/Rest/src/Authentication/JWTService.php
Normal file
113
module/Rest/src/Authentication/JWTService.php
Normal 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]);
|
||||
}
|
||||
}
|
47
module/Rest/src/Authentication/JWTServiceInterface.php
Normal file
47
module/Rest/src/Authentication/JWTServiceInterface.php
Normal 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);
|
||||
}
|
137
module/Rest/src/Entity/ApiKey.php
Normal file
137
module/Rest/src/Entity/ApiKey.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
106
module/Rest/src/Service/ApiKeyService.php
Normal file
106
module/Rest/src/Service/ApiKeyService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
47
module/Rest/src/Service/ApiKeyServiceInterface.php
Normal file
47
module/Rest/src/Service/ApiKeyServiceInterface.php
Normal 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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
93
module/Rest/test/Authentication/JWTServiceTest.php
Normal file
93
module/Rest/test/Authentication/JWTServiceTest.php
Normal 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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
168
module/Rest/test/Service/ApiKeyServiceTest.php
Normal file
168
module/Rest/test/Service/ApiKeyServiceTest.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue