Compare commits

...

47 Commits

Author SHA1 Message Date
Alejandro Celaya
065cddc4b1 Merge branch 'develop' 2016-08-09 19:05:39 +02:00
Alejandro Celaya
3a3a16f46f Added changelog for v1.1.0 2016-08-09 19:05:13 +02:00
Alejandro Celaya
ba5bd6d98c Merge branch 'develop' 2016-08-09 19:00:42 +02:00
Alejandro Celaya
a1aa9c2031 Merge pull request #49 from acelaya/develop
v1.1.0
2016-08-09 19:00:22 +02:00
Alejandro Celaya
43c6b56e42 Fixed memcached test while comparing servers 2016-08-09 18:56:01 +02:00
Alejandro Celaya
39d2f5a38f Created travis config file to enable memcached extension 2016-08-09 18:50:14 +02:00
Alejandro Celaya
69cc30bce7 Allowed failures on PHP 7.1 environments 2016-08-09 18:38:48 +02:00
Alejandro Celaya
5913550eec Fixed build when memcached is not enabled in PHP 7.1 2016-08-09 18:29:47 +02:00
Alejandro Celaya
3140ab2ad7 Updated shlink website link to use https 2016-08-09 18:17:28 +02:00
Alejandro Celaya
090479fa62 Improved CacheFactory supporting more adapters 2016-08-09 17:58:47 +02:00
Alejandro Celaya
9ee2064ba1 Merge pull request #2 from acelaya/feature/46
Feature/46
2016-08-09 14:21:43 +02:00
Alejandro Celaya
90cef7d4d9 Removed unused import 2016-08-09 14:19:46 +02:00
Alejandro Celaya
12410e82d8 Created tests for QrCode middlewares 2016-08-09 14:18:20 +02:00
Alejandro Celaya
18084433c7 Created middleware to cache generated QR codes 2016-08-09 13:41:30 +02:00
Alejandro Celaya
8eb279fd28 Updated UrlShortener to namespace the cache entries 2016-08-09 13:32:33 +02:00
Alejandro Celaya
99b7c77997 Created action to generate QR codes 2016-08-09 10:25:30 +02:00
Alejandro Celaya
166c94cac7 Merge branch 'feature/40' into develop 2016-08-09 09:13:48 +02:00
Alejandro Celaya
7c5d8cf244 Fixed VisitsTracker to take into account the X-Forwarded-For header in case the server is behind a load balabncer or proxy 2016-08-09 09:13:39 +02:00
Alejandro Celaya
73a236b3d0 Updated VisitsTracker so that the track method expects a Request object to be provided 2016-08-09 08:52:06 +02:00
Alejandro Celaya
34753ca7d3 Added logger to classes that catch errors in order to log them 2016-08-08 12:33:58 +02:00
Alejandro Celaya
fff058f44b Created LoggerFactoryTest 2016-08-08 12:07:04 +02:00
Alejandro Celaya
b7f3c332e4 Created Logger factory and logger config, and added logger dependencies 2016-08-08 11:56:19 +02:00
Alejandro Celaya
cff9b7c0b5 Deleted docs which are now in Shlink's website 2016-08-08 11:17:14 +02:00
Alejandro Celaya
63e867cf4b Updated vendor name on error pages 2016-08-08 10:08:34 +02:00
Alejandro Celaya
f49e9064cd Added cache adapter to the UrlShortener service to cache shortcode-url maps 2016-08-08 10:02:52 +02:00
Alejandro Celaya
3bd4f506e0 Updated status returned in REST endpoints to be 404 when something is not found 2016-08-08 09:46:40 +02:00
Alejandro Celaya
93713689d7 Merge branch 'feature/35' into develop 2016-08-08 09:39:15 +02:00
Alejandro Celaya
ecd2e6e759 Updated namespace for Visit CLI commands 2016-08-08 09:38:50 +02:00
Alejandro Celaya
a65003803b Updated namespace for Shortcode CLI commands 2016-08-08 09:36:52 +02:00
Alejandro Celaya
0a4f8c3b0a Merge pull request #1 from acelaya/feature/13
Feature/13
2016-08-07 21:15:59 +02:00
Alejandro Celaya
80d8c32881 Removed rest auth env vars 2016-08-07 20:47:43 +02:00
Alejandro Celaya
57bc681b9e Created command to generate a random secret key string 2016-08-07 20:30:19 +02:00
Alejandro Celaya
2a089f05b1 Updated languages 2016-08-07 20:21:38 +02:00
Alejandro Celaya
258f954a38 Deleted rest token related classes 2016-08-07 19:57:23 +02:00
Alejandro Celaya
7b0beb3b8c Updated CheckAuthenticationMiddleware to work with JWT and the Authorization header 2016-08-07 19:53:14 +02:00
Alejandro Celaya
9573e9f4ef Updated AuthenticateAction to generate and return a JWT 2016-08-07 19:13:40 +02:00
Alejandro Celaya
a60080b1ce Created JWTService and related classes 2016-08-07 14:44:33 +02:00
Alejandro Celaya
1d92e87d50 Updated AuthenticateAction to use the APiKeyService instead of the RestTokenService 2016-08-07 10:26:34 +02:00
Alejandro Celaya
289db45f27 Created ListKeysCommand 2016-08-06 18:50:50 +02:00
Alejandro Celaya
c5382b2a7f Created DisableKeyCommand 2016-08-06 18:26:07 +02:00
Alejandro Celaya
dd1bc49b79 Added method to ApiKeyService to list api keys 2016-08-06 18:08:09 +02:00
Alejandro Celaya
74777c2234 Created command to generate a new api key 2016-08-06 18:07:48 +02:00
Alejandro Celaya
99d7e6dd7d Fixed AuthenticateAction not working with only one group of params 2016-08-06 13:24:06 +02:00
Alejandro Celaya
7b746f76b0 Created APiKeyService and tests 2016-08-06 13:18:27 +02:00
Alejandro Celaya
2767a14101 Created ApiKey entity 2016-08-06 12:50:44 +02:00
Alejandro Celaya
270dbc6028 Created new entity_manager configuration, dropping old database first level config key 2016-08-06 12:40:31 +02:00
Alejandro Celaya
7b1b00901a Created phpstorm meta fle to get ContainerInterop typehint based on service name 2016-08-05 07:20:40 +02:00
88 changed files with 2475 additions and 835 deletions

View File

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

19
.phpstorm.meta.php Normal file
View File

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

1
.travis-php.ini Normal file
View File

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

View File

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

View File

@@ -1,5 +1,28 @@
## CHANGELOG
### 1.1.0
**Features**
* [46: Define a route that returns a QR code representing the shortened URL](https://github.com/acelaya/url-shortener/issues/46)
**Enhancements:**
* [32: Add support for other cache adapters by improving the Cache factory](https://github.com/acelaya/url-shortener/issues/32)
* [14: https://github.com/shlinkio/shlink/issues/14](https://github.com/acelaya/url-shortener/issues/14)
* [41: Cache the "short code" => "URL" map to prevent extra DB hits](https://github.com/acelaya/url-shortener/issues/41)
* [13: Improve REST authentication](https://github.com/acelaya/url-shortener/issues/13)
**Tasks**
* [39: Change copyright from "Alejandro Celaya" to "Shlink" in error pages](https://github.com/acelaya/url-shortener/issues/39)
* [42: Make REST endpoints that need to find something return a 404 when "something" is not found](https://github.com/acelaya/url-shortener/issues/42)
* [35: Make CLI commands to use the same PHP namespace as the one used for the command name](https://github.com/acelaya/url-shortener/issues/35)
**Bugs**
* [40: Take into account the X-Forwarded-For header in order to get the visitor information, in case the server is behind a load balancer or proxy](https://github.com/acelaya/url-shortener/issues/40)
### 1.0.0
**Enhancements:**

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
<?php
return [
'database' => [
'driver' => 'pdo_mysql',
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'dbname' => env('DB_NAME', 'shlink'),
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'
],
],
];

View File

@@ -0,0 +1,20 @@
<?php
return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
],
'connection' => [
'driver' => 'pdo_mysql',
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'dbname' => env('DB_NAME', 'shlink'),
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
],
],
],
];

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -10,13 +10,16 @@ return [
'factories' => [
Application::class => ApplicationFactory::class,
Command\GenerateShortcodeCommand::class => AnnotatedFactory::class,
Command\ResolveUrlCommand::class => AnnotatedFactory::class,
Command\ListShortcodesCommand::class => AnnotatedFactory::class,
Command\GetVisitsCommand::class => AnnotatedFactory::class,
Command\ProcessVisitsCommand::class => AnnotatedFactory::class,
Command\ProcessVisitsCommand::class => AnnotatedFactory::class,
Command\Shortcode\GenerateShortcodeCommand::class => AnnotatedFactory::class,
Command\Shortcode\ResolveUrlCommand::class => AnnotatedFactory::class,
Command\Shortcode\ListShortcodesCommand::class => AnnotatedFactory::class,
Command\Shortcode\GetVisitsCommand::class => AnnotatedFactory::class,
Command\Visit\ProcessVisitsCommand::class => AnnotatedFactory::class,
Command\Config\GenerateCharsetCommand::class => AnnotatedFactory::class,
Command\Config\GenerateSecretCommand::class => AnnotatedFactory::class,
Command\Api\GenerateKeyCommand::class => AnnotatedFactory::class,
Command\Api\DisableKeyCommand::class => AnnotatedFactory::class,
Command\Api\ListKeysCommand::class => AnnotatedFactory::class,
],
],

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\GetVisitsCommand;
use Shlinkio\Shlink\CLI\Command\Shortcode\GetVisitsCommand;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;

View File

@@ -4,7 +4,7 @@ namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ListShortcodesCommand;
use Shlinkio\Shlink\CLI\Command\Shortcode\ListShortcodesCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Application;

View File

@@ -4,7 +4,7 @@ namespace ShlinkioTest\Shlink\CLI\Command;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ProcessVisitsCommand;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitService;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
<?php
namespace Shlinkio\Shlink\Common\Factory;
use Cascade\Cascade;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class LoggerFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->has('config') ? $container->get('config') : [];
Cascade::fileConfig(isset($config['logger']) ? $config['logger'] : ['loggers' => []]);
// Compose requested logger name
$loggerName = isset($options) & isset($options['logger_name']) ? $options['logger_name'] : 'Logger';
$nameParts = explode('_', $requestedName);
if (count($nameParts) > 1) {
$loggerName = $nameParts[1];
}
return Cascade::getLogger($loggerName);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
<?php
namespace Shlinkio\Shlink\Core\Action;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Endroid\QrCode\QrCode;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Expressive\Router\RouterInterface;
use Zend\Stratigility\MiddlewareInterface;
class QrCodeAction implements MiddlewareInterface
{
/**
* @var RouterInterface
*/
private $router;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var LoggerInterface
*/
private $logger;
/**
* QrCodeAction constructor.
* @param RouterInterface $router
* @param UrlShortenerInterface $urlShortener
* @param LoggerInterface $logger
*
* @Inject({RouterInterface::class, UrlShortener::class, "Logger_Shlink"})
*/
public function __construct(
RouterInterface $router,
UrlShortenerInterface $urlShortener,
LoggerInterface $logger = null
) {
$this->router = $router;
$this->urlShortener = $urlShortener;
$this->logger = $logger ?: new NullLogger();
}
/**
* Process an incoming request and/or response.
*
* Accepts a server-side request and a response instance, and does
* something with them.
*
* If the response is not complete and/or further processing would not
* interfere with the work done in the middleware, or if the middleware
* wants to delegate to another process, it can use the `$out` callable
* if present.
*
* If the middleware does not return a value, execution of the current
* request is considered complete, and the response instance provided will
* be considered the response to return.
*
* Alternately, the middleware may return a response instance.
*
* Often, middleware will `return $out();`, with the assumption that a
* later middleware will return a response.
*
* @param Request $request
* @param Response $response
* @param null|callable $out
* @return null|Response
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
// Make sure the short URL exists for this short code
$shortCode = $request->getAttribute('shortCode');
try {
$shortUrl = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($shortUrl)) {
return $out($request, $response->withStatus(404), 'Not Found');
}
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e);
return $out($request, $response->withStatus(404), 'Not Found');
}
$path = $this->router->generateUri('long-url-redirect', ['shortCode' => $shortCode]);
$size = $this->getSizeParam($request);
$qrCode = new QrCode($request->getUri()->withPath($path)->withQuery(''));
$qrCode->setSize($size)
->setPadding(0);
return new QrCodeResponse($qrCode);
}
/**
* @param Request $request
* @return int
*/
protected function getSizeParam(Request $request)
{
$size = intval($request->getAttribute('size', 300));
if ($size < 50) {
return 50;
} elseif ($size > 1000) {
return 1000;
}
return $size;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
<?php
namespace Shlinkio\Shlink\Core\Middleware;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Doctrine\Common\Cache\Cache;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class QrCodeCacheMiddleware implements MiddlewareInterface
{
/**
* @var Cache
*/
private $cache;
/**
* QrCodeCacheMiddleware constructor.
* @param Cache $cache
*
* @Inject({Cache::class})
*/
public function __construct(Cache $cache)
{
$this->cache = $cache;
}
/**
* Process an incoming request and/or response.
*
* Accepts a server-side request and a response instance, and does
* something with them.
*
* If the response is not complete and/or further processing would not
* interfere with the work done in the middleware, or if the middleware
* wants to delegate to another process, it can use the `$out` callable
* if present.
*
* If the middleware does not return a value, execution of the current
* request is considered complete, and the response instance provided will
* be considered the response to return.
*
* Alternately, the middleware may return a response instance.
*
* Often, middleware will `return $out();`, with the assumption that a
* later middleware will return a response.
*
* @param Request $request
* @param Response $response
* @param null|callable $out
* @return null|Response
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$cacheKey = $request->getUri()->getPath();
// If this QR code is already cached, just return it
if ($this->cache->contains($cacheKey)) {
$qrData = $this->cache->fetch($cacheKey);
$response->getBody()->write($qrData['body']);
return $response->withHeader('Content-Type', $qrData['content-type']);
}
// If not, call the next middleware and cache it
/** @var Response $resp */
$resp = $out($request, $response);
$this->cache->save($cacheKey, [
'body' => $resp->getBody()->__toString(),
'content-type' => $resp->getHeaderLine('Content-Type'),
]);
return $resp;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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