Compare commits

..

119 Commits

Author SHA1 Message Date
Alejandro Celaya
611a314cdf Merge branch 'develop' 2016-08-29 13:07:25 +02:00
Alejandro Celaya
e7b4d24e5d Merge pull request #68 from acelaya/develop
Develop
2016-08-29 13:07:01 +02:00
Alejandro Celaya
cf60440288 Fixed possible PHP errors being missed while checking REST auth 2016-08-29 12:43:02 +02:00
Alejandro Celaya
15896045f3 Removed logic making visits to be returned for 2 days only if no start or end date were provided 2016-08-28 19:32:07 +02:00
Alejandro Celaya
a9f480ca99 Fixed error while checking an API key that doesn't exist 2016-08-28 09:46:11 +02:00
Alejandro Celaya
4bd67d5f98 Fixed cross domain middleware not exposing the Authorization header 2016-08-27 13:00:41 +02:00
Alejandro Celaya
924ba58f73 Added swagger documentation file 2016-08-26 11:51:51 +02:00
Alejandro Celaya
6eef694315 Merge branch 'develop' 2016-08-21 21:24:31 +02:00
Alejandro Celaya
fe4a4aef34 Updated changelog 2016-08-21 21:24:00 +02:00
Alejandro Celaya
b13c95cf1a Placed cross domain middleware as the first one for rest requests 2016-08-21 21:21:31 +02:00
Alejandro Celaya
c1c325588e Merge branch 'develop' 2016-08-21 19:16:36 +02:00
Alejandro Celaya
faad60f79e Merge branch 'develop' of https://github.com/shlinkio/shlink into develop 2016-08-21 19:16:21 +02:00
Alejandro Celaya
cbb5a02b95 Kept symlincs while generating the dist file 2016-08-21 19:16:06 +02:00
Alejandro Celaya
96faafd31e Merge branch 'develop' 2016-08-21 18:31:45 +02:00
Alejandro Celaya
7aa42ada54 Merge pull request #61 from acelaya/develop
Develop
2016-08-21 18:31:32 +02:00
Alejandro Celaya
3b1545a761 Added v1.2.0 to changelog 2016-08-21 18:26:28 +02:00
Alejandro Celaya
536309afb6 Merge pull request #4 from acelaya/feature/45
Feature/45
2016-08-21 18:20:56 +02:00
Alejandro Celaya
ad6ef22b72 Updated languages 2016-08-21 18:17:39 +02:00
Alejandro Celaya
4185015bef Improved tests 2016-08-21 18:15:25 +02:00
Alejandro Celaya
3376725152 Created EditTagsActiontest 2016-08-21 18:06:34 +02:00
Alejandro Celaya
d7b18776f1 Improved error response in edit tag request 2016-08-21 17:55:26 +02:00
Alejandro Celaya
1da285a63a Created action to set the togas for a short url 2016-08-21 16:52:26 +02:00
Alejandro Celaya
372488cbb4 Created middleware to parse PUT request bodies in rest requests 2016-08-21 13:52:15 +02:00
Alejandro Celaya
b6fee0ebaf Added option to set tags while creating short code from rest API 2016-08-21 13:07:12 +02:00
Alejandro Celaya
322180bde4 Added tag property to json serialization of ShortUrl 2016-08-21 12:48:31 +02:00
Alejandro Celaya
1cf6c93007 Added option to pass tags when creating a short code from the command line 2016-08-21 10:39:00 +02:00
Alejandro Celaya
2b89556c09 Allowed to display tags in the shortcode:list command 2016-08-21 10:17:17 +02:00
Alejandro Celaya
e3021120e3 Checked tables do not exist before creating them 2016-08-21 09:45:36 +02:00
Alejandro Celaya
2ca7ab4ccf Created new entity Tag and migration to create new tables 2016-08-21 09:41:21 +02:00
Alejandro Celaya
a2c2bc166c Removed wrong spaces 2016-08-20 19:11:20 +02:00
Alejandro Celaya
5f0baab2ac Created script to update an already existing system 2016-08-19 15:50:08 +02:00
Alejandro Celaya
ea083e30b6 Improved InstallCommand, adding migrations and removing code duplication 2016-08-19 15:15:53 +02:00
Alejandro Celaya
7d49c1760c Added doctrine migrations and remove platform specific code from entities 2016-08-19 14:51:34 +02:00
Alejandro Celaya
7c42835cc1 Improved error management on install command 2016-08-18 18:02:24 +02:00
Alejandro Celaya
f77273ef93 Merge pull request #3 from acelaya/feature/7
Feature/7
2016-08-18 17:30:57 +02:00
Alejandro Celaya
e52983c5d9 Updated translations 2016-08-18 17:28:04 +02:00
Alejandro Celaya
4615bbaaf7 CreatedPreviewActionTest 2016-08-18 17:23:40 +02:00
Alejandro Celaya
b432ed2c1d Created GeneratePreviewCommandTest 2016-08-18 17:10:40 +02:00
Alejandro Celaya
34174b2fbe Added tests for Image classes 2016-08-18 16:37:50 +02:00
Alejandro Celaya
fac519699a Used filesystem check instead of cache check for preview generation 2016-08-18 13:20:57 +02:00
Alejandro Celaya
2c91ded514 Improved PreviewGenerator by composing an ImageBuilder that creates new Image objects fore each URL 2016-08-18 12:21:26 +02:00
Alejandro Celaya
15247e832e Improved QR code generation route 2016-08-18 11:31:04 +02:00
Alejandro Celaya
60c68c914b Managed error while generating URL previews by throwing an exception 2016-08-18 11:17:17 +02:00
Alejandro Celaya
277406c3b8 Created action to return preview images 2016-08-18 11:10:15 +02:00
Alejandro Celaya
26adf48b48 Added wkhtmltopdf stuff and created preview generator service 2016-08-18 10:19:33 +02:00
Alejandro Celaya
20e43aac90 Added bin dir to code sniffer checks 2016-08-17 11:47:08 +02:00
Alejandro Celaya
56c9abcfd0 Fixed build script to generate the distributable file just with project files from the project root 2016-08-16 11:51:05 +02:00
Alejandro Celaya
5ca4bc928d Created specific factory for AppOptions to prevent circular dependency with cache 2016-08-15 23:40:49 +02:00
Alejandro Celaya
ffa6c0d2ca Merge branch 'feature/install' into develop 2016-08-15 11:34:48 +02:00
Alejandro Celaya
395311eaad Added InstallCommandTest 2016-08-15 11:34:35 +02:00
Alejandro Celaya
5bbc7de4af Fixed namespace in some tests 2016-08-15 11:01:55 +02:00
Alejandro Celaya
d5516c7269 Finished build script to compress dist project file 2016-08-15 10:18:13 +02:00
Alejandro Celaya
f852f9d398 Created htaccess 2016-08-15 10:03:39 +02:00
Alejandro Celaya
0d9f964687 Fixed bug on build script 2016-08-15 10:03:17 +02:00
Alejandro Celaya
00c56ca594 Fixed tests 2016-08-15 09:52:44 +02:00
Alejandro Celaya
9e4fb68265 Created build script 2016-08-15 09:45:09 +02:00
Alejandro Celaya
804d99ebf7 Created CLI scripts for Windows OS 2016-08-15 09:21:14 +02:00
Alejandro Celaya
4bbdccf981 Added symfony process to run initialization commands 2016-08-14 23:41:42 +02:00
Alejandro Celaya
1f3e31d100 Fixed working directory and paths in InstallCommand 2016-08-14 23:25:04 +02:00
Alejandro Celaya
2617ef1547 Added cli locale to installation generated config file 2016-08-14 23:10:07 +02:00
Alejandro Celaya
25380e4727 Moved console Application creation to factory 2016-08-14 23:08:26 +02:00
Alejandro Celaya
12322b7368 Improved config loading by using only one config provider object 2016-08-14 22:56:22 +02:00
Alejandro Celaya
a608f7d0f4 Minor name change 2016-08-14 21:28:21 +02:00
Alejandro Celaya
9a42d70604 Added custom config params to merged confg 2016-08-14 18:15:10 +02:00
Alejandro Celaya
fe708333b1 Fixed path while generating config file 2016-08-14 10:43:16 +02:00
Alejandro Celaya
566ee7ef6f Finished custom config command 2016-08-14 10:30:43 +02:00
Alejandro Celaya
56af58fcb8 Created installation script and installation command 2016-08-14 09:11:46 +02:00
Alejandro Celaya
cffa43a155 Created installation script and installation command 2016-08-14 09:09:23 +02:00
Alejandro Celaya
2b2c0b7c13 Merge branch 'feature/29' into develop 2016-08-12 18:01:38 +02:00
Alejandro Celaya
faa8019fc5 Redefined logger services so that the error handler uses Shlink's logger 2016-08-12 18:01:09 +02:00
Alejandro Celaya
a8ea458649 Fixed distributable local config file 2016-08-12 17:55:07 +02:00
Alejandro Celaya
8f4d305982 Added ErrorHandler package dependency and remove local files 2016-08-12 17:54:32 +02:00
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
160 changed files with 5034 additions and 1251 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=

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ build
composer.lock
vendor/
.env
data/database.sqlite

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,59 @@
## CHANGELOG
### 1.2.1
**Bugs**
* [62: Fix cross-domain requests in REST API](https://github.com/acelaya/url-shortener/issues/62)
### 1.2.0
**Features**
* [45: Allow to define tags on short codes, to improve filtering and classification](https://github.com/acelaya/url-shortener/issues/45)
* [7: Add website previews while listing available URLs](https://github.com/acelaya/url-shortener/issues/7)
**Enhancements:**
* [57: Add database migrations system to improve updating between versions](https://github.com/acelaya/url-shortener/issues/57)
* [31: Add support for other database management systems by improving the EntityManager factory](https://github.com/acelaya/url-shortener/issues/31)
* [51: Generate build process to paquetize the app and ease distribution](https://github.com/acelaya/url-shortener/issues/51)
* [38: Define installation script. It will request dynamic data on the fly so that there is no need to define env vars](https://github.com/acelaya/url-shortener/issues/38)
**Tasks**
* [55: Create update script which does not try to create a new database](https://github.com/acelaya/url-shortener/issues/55)
* [54: Add cache namespace to prevent name collisions with other apps in the same environment](https://github.com/acelaya/url-shortener/issues/54)
* [29: Use the acelaya/ze-content-based-error-handler package instead of custom error handler implementation](https://github.com/acelaya/url-shortener/issues/29)
**Bugs**
* [53: Fix entities database interoperability](https://github.com/acelaya/url-shortener/issues/53)
* [52: Add missing htaccess file for apache environments](https://github.com/acelaya/url-shortener/issues/52)
### 1.1.0
**Features**
* [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

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

14
bin/install Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Symfony\Component\Console\Application;
use Zend\Config\Writer\PhpArray;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$app = new Application();
$app->add(new InstallCommand(new PhpArray()));
$app->setDefaultCommand('shlink:install');
$app->run();

14
bin/update Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
use Shlinkio\Shlink\CLI\Command\Install\UpdateCommand;
use Symfony\Component\Console\Application;
use Zend\Config\Writer\PhpArray;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$app = new Application();
$app->add(new UpdateCommand(new PhpArray()));
$app->setDefaultCommand('shlink:install');
$app->run();

BIN
bin/wkhtmltoimage Executable file

Binary file not shown.

44
build.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -e
if [ "$#" -ne 1 ]; then
echo "Usage:" >&2
echo " $0 {version}" >&2
exit 1
fi
version=$1
builtcontent=$(readlink -f '../shlink_build_tmp')
projectdir=$(pwd)
# Copy project content to temp dir
echo 'Copying project files...'
rm -rf "${builtcontent}"
mkdir "${builtcontent}"
cp -R "${projectdir}"/* "${builtcontent}"
cd "${builtcontent}"
# Install dependencies
rm -r vendor
rm composer.lock
composer self-update
composer install --no-dev --optimize-autoloader
# Delete development files
echo 'Deleting dev files...'
rm build.sh
rm CHANGELOG.md
rm composer.*
rm LICENSE
rm php*
rm README.md
rm -r build
rm -f data/database.sqlite
rm -rf data/{cache,log,proxies}/{*,.gitignore}
rm -rf config/params/{*,.gitignore}
rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
# Compressing file
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip .
rm -rf "${builtcontent}"

View File

@@ -23,9 +23,18 @@
"zendframework/zend-i18n": "^2.7",
"mtymek/expressive-config-manager": "^0.4",
"acelaya/zsm-annotated-services": "^0.2.0",
"acelaya/ze-content-based-error-handler": "^1.0",
"doctrine/orm": "^2.5",
"guzzlehttp/guzzle": "^6.2",
"symfony/console": "^3.0"
"symfony/console": "^3.0",
"symfony/process": "^3.0",
"symfony/filesystem": "^3.0",
"firebase/php-jwt": "^4.0",
"monolog/monolog": "^1.21",
"theorchard/monolog-cascade": "^0.4",
"endroid/qrcode": "^1.7",
"mikehaertl/phpwkhtmltopdf": "^2.2",
"doctrine/migrations": "^1.4"
},
"require-dev": {
"phpunit/phpunit": "^5.0",
@@ -64,5 +73,8 @@
"serve": "php -S 0.0.0.0:8000 -t public/",
"test": "phpunit --coverage-clover build/clover.xml",
"pretty-test": "phpunit --coverage-html build/coverage"
},
"config": {
"process-timeout": 0
}
}

View File

@@ -0,0 +1,10 @@
<?php
return [
'app_options' => [
'name' => 'Shlink',
'version' => '1.2.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

@@ -1,5 +1,4 @@
<?php
use Shlinkio\Shlink\Common\ErrorHandler\ContentBasedErrorHandler;
use Zend\Expressive;
use Zend\Expressive\Container;
use Zend\Expressive\Router;
@@ -17,7 +16,6 @@ return [
],
'aliases' => [
Router\RouterInterface::class => Router\FastRouteRouter::class,
'Zend\Expressive\FinalHandler' => ContentBasedErrorHandler::class,
],
],

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,5 +1,5 @@
<?php
use Shlinkio\Shlink\Common\ErrorHandler\ContentBasedErrorHandler;
use Acelaya\ExpressiveErrorHandler\ErrorHandler\ContentBasedErrorHandler;
use Zend\Expressive\Container\WhoopsErrorHandlerFactory;
return [

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

@@ -0,0 +1,11 @@
<?php
return [
'phpwkhtmltopdf' => [
'images' => [
'binary' => 'bin/wkhtmltoimage',
'type' => 'jpg',
],
],
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'preview_generation' => [
'files_location' => 'data/cache',
],
];

View File

@@ -1,10 +1,10 @@
<?php
use Acelaya\ExpressiveErrorHandler;
use Shlinkio\Shlink\CLI;
use Shlinkio\Shlink\Common;
use Shlinkio\Shlink\Core;
use Shlinkio\Shlink\Rest;
use Zend\Expressive\ConfigManager\ConfigManager;
use Zend\Expressive\ConfigManager\ZendConfigProvider;
use Zend\Expressive\ConfigManager;
/**
* Configuration files are loaded in a specific order. First ``global.php``, then ``*.global.php``.
@@ -15,10 +15,11 @@ use Zend\Expressive\ConfigManager\ZendConfigProvider;
* Obviously, if you use closures in your config you can't cache it.
*/
return (new ConfigManager([
return (new ConfigManager\ConfigManager([
ExpressiveErrorHandler\ConfigProvider::class,
Common\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ZendConfigProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
new ConfigManager\ZendConfigProvider('config/{autoload/{{,*.}global,{,*.}local},params/generated_config}.php'),
], 'data/cache/app_config.php'))->getMergedConfig();

2
config/params/.gitignore vendored Normal file
View File

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

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

@@ -0,0 +1,39 @@
<?php
namespace ShlinkMigrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20160819142757 extends AbstractMigration
{
const MYSQL = 'mysql';
const SQLITE = 'sqlite';
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
$db = $this->connection->getDatabasePlatform()->getName();
$table = $schema->getTable('short_urls');
$column = $table->getColumn('short_code');
if ($db === self::MYSQL) {
$column->setPlatformOption('collation', 'utf8_bin');
} elseif ($db === self::SQLITE) {
$column->setPlatformOption('collate', 'BINARY');
}
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
$db = $this->connection->getDatabasePlatform()->getName();
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace ShlinkMigrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Type;
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20160820191203 extends AbstractMigration
{
/**
* @param Schema $schema
*/
public function up(Schema $schema)
{
// Check if the tables already exist
$tables = $schema->getTables();
foreach ($tables as $table) {
if ($table->getName() === 'tags') {
return;
}
}
$this->createTagsTable($schema);
$this->createShortUrlsInTagsTable($schema);
}
protected function createTagsTable(Schema $schema)
{
$table = $schema->createTable('tags');
$table->addColumn('id', Type::BIGINT, [
'unsigned' => true,
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('name', Type::STRING, [
'length' => 255,
'notnull' => true,
]);
$table->addUniqueIndex(['name']);
$table->setPrimaryKey(['id']);
}
protected function createShortUrlsInTagsTable(Schema $schema)
{
$table = $schema->createTable('short_urls_in_tags');
$table->addColumn('short_url_id', Type::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addColumn('tag_id', Type::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addForeignKeyConstraint('tags', ['tag_id'], ['id'], [
'onDelete' => 'CASCADE',
'onUpdate' => 'RESTRICT',
]);
$table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [
'onDelete' => 'CASCADE',
'onUpdate' => 'RESTRICT',
]);
$table->setPrimaryKey(['short_url_id', 'tag_id']);
}
/**
* @param Schema $schema
*/
public function down(Schema $schema)
{
$schema->dropTable('short_urls_in_tags');
$schema->dropTable('tags');
}
}

289
docs/swagger.yaml Normal file
View File

@@ -0,0 +1,289 @@
swagger: '2.0'
info:
title: Shlink
description: Shlink, the self-hosted URL shortener
version: "1.2.0"
schemes:
- https
basePath: /rest
produces:
- application/json
paths:
/authenticate:
post:
description: Performs an authentication
parameters:
- name: apiKey
in: formData
description: The API key to authenticate with
required: true
type: string
responses:
200:
description: The authentication worked.
schema:
type: object
properties:
token:
type: string
description: The authentication token that needs to be sent in the Authorization header
400:
description: An API key was not provided.
schema:
$ref: '#/definitions/Error'
401:
description: The API key is incorrect, is disabled or has expired.
schema:
$ref: '#/definitions/Error'
500:
description: Unexpected error.
schema:
$ref: '#/definitions/Error'
/short-codes:
get:
description: Returns the list of short codes
parameters:
- name: page
in: query
description: The page to be displayed. Defaults to 1
required: false
type: integer
- name: Authorization
in: header
description: The authorization token with Bearer type
required: true
type: string
responses:
200:
description: The list of short URLs
schema:
type: object
properties:
shortUrls:
type: object
properties:
data:
type: array
items:
$ref: '#/definitions/ShortUrl'
pagination:
$ref: '#/definitions/Pagination'
500:
description: Unexpected error.
schema:
$ref: '#/definitions/Error'
post:
description: Creates a new short code
parameters:
- name: longUrl
in: formData
description: The URL to parse
required: true
type: string
- name: tags
in: formData
description: The URL to parse
required: false
type: array
items:
type: string
- name: Authorization
in: header
description: The authorization token with Bearer type
required: true
type: string
responses:
200:
description: The result of parsing the long URL
schema:
type: object
properties:
longUrl:
type: string
description: The original long URL that has been parsed
shortUrl:
type: string
description: The generated short URL
shortCode:
type: string
description: the short code that is being used in the short URL
400:
description: The long URL was not provided or is invalid.
schema:
$ref: '#/definitions/Error'
500:
description: Unexpected error.
schema:
$ref: '#/definitions/Error'
/short-codes/{shortCode}:
get:
description: Get the long URL behind a short code.
parameters:
- name: shortCode
in: path
type: string
description: The short code to resolve.
required: true
- name: Authorization
in: header
description: The authorization token with Bearer type
required: true
type: string
responses:
200:
description: The long URL behind a short code.
schema:
type: object
properties:
longUrl:
type: string
description: The original long URL behind the short code.
404:
description: No URL was found for provided short code.
schema:
$ref: '#/definitions/Error'
400:
description: Provided shortCode does not match the character set currently used by the app to generate short codes.
schema:
$ref: '#/definitions/Error'
500:
description: Unexpected error.
schema:
$ref: '#/definitions/Error'
/short-codes/{shortCode}/visits:
get:
description: Get the list of visits on provided short code.
parameters:
- name: shortCode
in: path
type: string
description: The shortCode from which we want to get the visits.
required: true
- name: Authorization
in: header
description: The authorization token with Bearer type
required: true
type: string
responses:
200:
description: List of visits.
schema:
type: object
properties:
visits:
type: object
properties:
data:
type: array
items:
$ref: '#/definitions/Visit'
404:
description: The short code does not belong to any short URL.
schema:
$ref: '#/definitions/Error'
500:
description: Unexpected error.
schema:
$ref: '#/definitions/Error'
/short-codes/{shortCode}/tags:
put:
description: Edit the tags on provided short code.
parameters:
- name: shortCode
in: path
type: string
description: The shortCode in which we want to edit tags.
required: true
- name: tags
in: formData
type: array
items:
type: string
description: The list of tags to set to the short URL.
required: true
- name: Authorization
in: header
description: The authorization token with Bearer type
required: true
type: string
responses:
200:
description: List of tags.
schema:
type: object
properties:
tags:
type: array
items:
type: string
400:
description: The request body does not contain a "tags" param with array type.
schema:
$ref: '#/definitions/Error'
404:
description: No short URL was found for provided short code.
schema:
$ref: '#/definitions/Error'
500:
description: Unexpected error.
schema:
$ref: '#/definitions/Error'
definitions:
ShortUrl:
type: object
properties:
shortCode:
type: string
description: The short code for this short URL.
originalUrl:
type: string
description: The original long URL.
dateCreated:
type: string
format: date-time
description: The date in which the short URL was created in ISO format.
visitsCount:
type: integer
description: The number of visits that this short URL has recieved.
tags:
type: array
items:
type: string
description: A list of tags applied to this short URL
Visit:
type: object
properties:
referer:
type: string
date:
type: string
format: date-time
remoteAddr:
type: string
userAgent:
type: string
Error:
type: object
properties:
code:
type: string
description: A machine unique code
message:
type: string
description: A human-friendly error message
Pagination:
type: object
properties:
currentPage:
type: integer
description: The number of current page being displayed.
pagesCount:
type: integer
description: The total number of pages that can be displayed.

4
migrations.yml Normal file
View File

@@ -0,0 +1,4 @@
name: ShlinkMigrations
migrations_namespace: ShlinkMigrations
table_name: migrations
migrations_directory: data/migrations

View File

@@ -4,13 +4,19 @@ use Shlinkio\Shlink\CLI\Command;
return [
'cli' => [
'locale' => env('CLI_LOCALE', 'en'),
'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\Shortcode\GeneratePreviewCommand::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,17 @@ 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\Shortcode\GeneratePreviewCommand::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-21 18:16+0200\n"
"PO-Revision-Date: 2016-08-21 18:16+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\". "
@@ -28,7 +68,35 @@ msgstr ""
msgid "Character set:"
msgstr "Grupo de caracteres:"
#, fuzzy
msgid ""
"Generates a random secret string that can be used for JWT token encryption"
msgstr ""
"Genera una cadena de caracteres aleatoria que puede ser usada para cifrar "
"tokens JWT"
msgid "Secret key:"
msgstr "Clave secreta:"
msgid ""
"Processes and generates the previews for every URL, improving performance "
"for later web requests."
msgstr ""
"Procesa y genera las vistas previas para cada URL, mejorando el rendimiento "
"para peticiones web posteriores."
msgid "Finished processing all URLs"
msgstr "Finalizado el procesado de todas las URLs"
#, php-format
msgid "Processing URL %s..."
msgstr "Procesando URL %s..."
msgid " <info>Success!</info>"
msgstr "<info>¡Correcto!</info>"
msgid "Error"
msgstr "Error"
msgid "Generates a short code for provided URL and returns the short URL"
msgstr ""
"Genera un código corto para la URL proporcionada y devuelve la URL acortada"
@@ -36,6 +104,9 @@ msgstr ""
msgid "The long URL to parse"
msgstr "La URL larga a procesar"
msgid "Tags to apply to the new short URL"
msgstr "Etiquetas a aplicar a la nueva URL acortada"
msgid "A long URL was not provided. Which URL do you want to shorten?:"
msgstr "No se ha proporcionado una URL larga. ¿Qué URL deseas acortar?"
@@ -91,6 +162,9 @@ msgstr "Listar todas las URLs cortas"
msgid "The first page to list (%s items per page)"
msgstr "La primera página a listar (%s elementos por página)"
msgid "Whether to display the tags or not"
msgstr "Si se desea mostrar las etiquetas o no"
msgid "Short code"
msgstr "Código corto"
@@ -103,28 +177,15 @@ msgstr "Fecha de creación"
msgid "Visits count"
msgstr "Número de visitas"
msgid "Tags"
msgstr "Etiquetas"
msgid "You have reached last page"
msgstr "Has alcanzado la última página"
msgid "Continue with page"
msgstr "Continuar con la página"
msgid "Processes visits where location is not set yet"
msgstr "Procesa las visitas donde la localización no ha sido establecida aún"
msgid "Processing IP"
msgstr "Procesando IP"
msgid "Ignored localhost address"
msgstr "Ignorada IP de localhost"
#, php-format
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"
msgid "Returns the long URL behind a short code"
msgstr "Devuelve la URL larga detrás de un código corto"
@@ -145,3 +206,19 @@ msgstr "URL larga:"
#, php-format
msgid "Provided short code \"%s\" has an invalid format."
msgstr "El código corto proporcionado \"%s\" tiene un formato inválido."
msgid "Processes visits where location is not set yet"
msgstr "Procesa las visitas donde la localización no ha sido establecida aún"
msgid "Processing IP"
msgstr "Procesando IP"
msgid "Ignored localhost address"
msgstr "Ignorada IP de localhost"
#, php-format
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"

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

@@ -0,0 +1,307 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Install;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\Question;
use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command
{
use StringUtilsTrait;
const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
const SUPPORTED_LANGUAGES = ['en', 'es'];
/**
* @var InputInterface
*/
private $input;
/**
* @var OutputInterface
*/
private $output;
/**
* @var QuestionHelper
*/
private $questionHelper;
/**
* @var ProcessHelper
*/
private $processHelper;
/**
* @var WriterInterface
*/
private $configWriter;
/**
* InstallCommand constructor.
* @param WriterInterface $configWriter
* @param callable|null $databaseCreationLogic
*/
public function __construct(WriterInterface $configWriter)
{
parent::__construct(null);
$this->configWriter = $configWriter;
}
public function configure()
{
$this->setName('shlink:install')
->setDescription('Installs Shlink');
}
public function execute(InputInterface $input, OutputInterface $output)
{
$this->input = $input;
$this->output = $output;
$this->questionHelper = $this->getHelper('question');
$this->processHelper = $this->getHelper('process');
$params = [];
$output->writeln([
'<info>Welcome to Shlink!!</info>',
'This process will guide you through the installation.',
]);
// Check if a cached config file exists and drop it if so
if (file_exists('data/cache/app_config.php')) {
$output->write('Deleting old cached config...');
if (unlink('data/cache/app_config.php')) {
$output->writeln(' <info>Success</info>');
} else {
$output->writeln(
' <error>Failed!</error> You will have to manually delete the data/cache/app_config.php file to get'
. ' new config applied.'
);
}
}
// Ask for custom config params
$params['DATABASE'] = $this->askDatabase();
$params['URL_SHORTENER'] = $this->askUrlShortener();
$params['LANGUAGE'] = $this->askLanguage();
$params['APP'] = $this->askApplication();
// Generate config params files
$config = $this->buildAppConfig($params);
$this->configWriter->toFile('config/params/generated_config.php', $config, false);
$output->writeln(['<info>Custom configuration properly generated!</info>', '']);
// Generate database
if (! $this->createDatabase()) {
return;
}
// Run database migrations
$output->writeln('Updating database...');
if (! $this->runCommand('php vendor/bin/doctrine-migrations migrations:migrate', 'Error updating database.')) {
return;
}
// Generate proxies
$output->writeln('Generating proxies...');
if (! $this->runCommand('php vendor/bin/doctrine.php orm:generate-proxies', 'Error generating proxies.')) {
return;
}
}
protected function askDatabase()
{
$params = [];
$this->printTitle('DATABASE');
// Select database type
$databases = array_keys(self::DATABASE_DRIVERS);
$dbType = $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select database type (defaults to ' . $databases[0] . '):</question>',
$databases,
0
));
$params['DRIVER'] = self::DATABASE_DRIVERS[$dbType];
// Ask for connection params if database is not SQLite
if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) {
$params['NAME'] = $this->ask('Database name', 'shlink');
$params['USER'] = $this->ask('Database username');
$params['PASSWORD'] = $this->ask('Database password');
}
return $params;
}
protected function askUrlShortener()
{
$this->printTitle('URL SHORTENER');
// Ask for URL shortener params
return [
'SCHEMA' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select schema for generated short URLs (defaults to http):</question>',
['http', 'https'],
0
)),
'HOSTNAME' => $this->ask('Hostname for generated URLs'),
'CHARS' => $this->ask(
'Character set for generated short codes (leave empty to autogenerate one)',
null,
true
) ?: str_shuffle(UrlShortener::DEFAULT_CHARS)
];
}
protected function askLanguage()
{
$this->printTitle('LANGUAGE');
return [
'DEFAULT' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select default language for the application in general (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
'CLI' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion(
'<question>Select default language for CLI executions (defaults to '
. self::SUPPORTED_LANGUAGES[0] . '):</question>',
self::SUPPORTED_LANGUAGES,
0
)),
];
}
protected function askApplication()
{
$this->printTitle('APPLICATION');
return [
'SECRET' => $this->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)',
null,
true
) ?: $this->generateRandomString(32),
];
}
/**
* @param string $text
*/
protected function printTitle($text)
{
$text = trim($text);
$length = strlen($text) + 4;
$header = str_repeat('*', $length);
$this->output->writeln([
'',
'<info>' . $header . '</info>',
'<info>* ' . strtoupper($text) . ' *</info>',
'<info>' . $header . '</info>',
]);
}
/**
* @param string $text
* @param string|null $default
* @param bool $allowEmpty
* @return string
*/
protected function ask($text, $default = null, $allowEmpty = false)
{
if (isset($default)) {
$text .= ' (defaults to ' . $default . ')';
}
do {
$value = $this->questionHelper->ask($this->input, $this->output, new Question(
'<question>' . $text . ':</question> ',
$default
));
if (empty($value) && ! $allowEmpty) {
$this->output->writeln('<error>Value can\'t be empty</error>');
}
} while (empty($value) && empty($default) && ! $allowEmpty);
return $value;
}
/**
* @param array $params
* @return array
*/
protected function buildAppConfig(array $params)
{
// Build simple config
$config = [
'app_options' => [
'secret_key' => $params['APP']['SECRET'],
],
'entity_manager' => [
'connection' => [
'driver' => $params['DATABASE']['DRIVER'],
],
],
'translator' => [
'locale' => $params['LANGUAGE']['DEFAULT'],
],
'cli' => [
'locale' => $params['LANGUAGE']['CLI'],
],
'url_shortener' => [
'domain' => [
'schema' => $params['URL_SHORTENER']['SCHEMA'],
'hostname' => $params['URL_SHORTENER']['HOSTNAME'],
],
'shortcode_chars' => $params['URL_SHORTENER']['CHARS'],
],
];
// Build dynamic database config
if ($params['DATABASE']['DRIVER'] === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = 'data/database.sqlite';
} else {
$config['entity_manager']['connection']['user'] = $params['DATABASE']['USER'];
$config['entity_manager']['connection']['password'] = $params['DATABASE']['PASSWORD'];
$config['entity_manager']['connection']['dbname'] = $params['DATABASE']['NAME'];
}
return $config;
}
protected function createDatabase()
{
$this->output->writeln('Initializing database...');
return $this->runCommand('php vendor/bin/doctrine.php orm:schema-tool:create', 'Error generating database.');
}
/**
* @param string $command
* @param string $errorMessage
* @return bool
*/
protected function runCommand($command, $errorMessage)
{
$process = $this->processHelper->run($this->output, $command);
if ($process->isSuccessful()) {
$this->output->writeln(' <info>Success!</info>');
return true;
} else {
if ($this->output->isVerbose()) {
return false;
}
$this->output->writeln(
' <error>' . $errorMessage . '</error> Run this command with -vvv to see specific error info.'
);
return false;
}
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Install;
use Zend\Config\Writer\WriterInterface;
class UpdateCommand extends InstallCommand
{
public function createDatabase()
{
return true;
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Shlinkio\Shlink\CLI\Command\Shortcode;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Zend\I18n\Translator\TranslatorInterface;
class GeneratePreviewCommand extends Command
{
/**
* @var PreviewGeneratorInterface
*/
private $previewGenerator;
/**
* @var TranslatorInterface
*/
private $translator;
/**
* @var ShortUrlServiceInterface
*/
private $shortUrlService;
/**
* GeneratePreviewCommand constructor.
* @param ShortUrlServiceInterface $shortUrlService
* @param PreviewGeneratorInterface $previewGenerator
* @param TranslatorInterface $translator
*
* @Inject({ShortUrlService::class, PreviewGenerator::class, "translator"})
*/
public function __construct(
ShortUrlServiceInterface $shortUrlService,
PreviewGeneratorInterface $previewGenerator,
TranslatorInterface $translator
) {
$this->shortUrlService = $shortUrlService;
$this->previewGenerator = $previewGenerator;
$this->translator = $translator;
parent::__construct(null);
}
public function configure()
{
$this->setName('shortcode:process-previews')
->setDescription(
$this->translator->translate(
'Processes and generates the previews for every URL, improving performance for later web requests.'
)
);
}
public function execute(InputInterface $input, OutputInterface $output)
{
$page = 1;
do {
$shortUrls = $this->shortUrlService->listShortUrls($page);
$page += 1;
foreach ($shortUrls as $shortUrl) {
$this->processUrl($shortUrl->getOriginalUrl(), $output);
}
} while ($page <= $shortUrls->count());
$output->writeln('<info>' . $this->translator->translate('Finished processing all URLs') . '</info>');
}
protected function processUrl($url, OutputInterface $output)
{
try {
$output->write(sprintf($this->translator->translate('Processing URL %s...'), $url));
$this->previewGenerator->generatePreview($url);
$output->writeln($this->translator->translate(' <info>Success!</info>'));
} catch (PreviewGenerationException $e) {
$messages = [' <error>' . $this->translator->translate('Error') . '</error>'];
if ($output->isVerbose()) {
$messages[] = '<error>' . $e->__toString() . '</error>';
}
$output->writeln($messages);
}
}
}

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;
@@ -9,6 +9,7 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Zend\Diactoros\Uri;
@@ -31,7 +32,7 @@ class GenerateShortcodeCommand extends Command
/**
* GenerateShortcodeCommand constructor.
* @param UrlShortenerInterface|UrlShortener $urlShortener
* @param UrlShortenerInterface $urlShortener
* @param TranslatorInterface $translator
* @param array $domainConfig
*
@@ -54,7 +55,13 @@ class GenerateShortcodeCommand extends Command
->setDescription(
$this->translator->translate('Generates a short code for provided URL and returns the short URL')
)
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'));
->addArgument('longUrl', InputArgument::REQUIRED, $this->translator->translate('The long URL to parse'))
->addOption(
'tags',
't',
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL,
$this->translator->translate('Tags to apply to the new short URL')
);
}
public function interact(InputInterface $input, OutputInterface $output)
@@ -80,6 +87,13 @@ class GenerateShortcodeCommand extends Command
public function execute(InputInterface $input, OutputInterface $output)
{
$longUrl = $input->getArgument('longUrl');
$tags = $input->getOption('tags');
$processedTags = [];
foreach ($tags as $key => $tag) {
$explodedTags = explode(',', $tag);
$processedTags = array_merge($processedTags, $explodedTags);
}
$tags = $processedTags;
try {
if (! isset($longUrl)) {
@@ -87,10 +101,10 @@ class GenerateShortcodeCommand extends Command
return;
}
$shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl));
$shortCode = $this->urlShortener->urlToShortCode(new Uri($longUrl), $tags);
$shortUrl = (new Uri())->withPath($shortCode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
$output->writeln([
sprintf('%s <info>%s</info>', $this->translator->translate('Processed URL:'), $longUrl),

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"})
@@ -55,12 +55,20 @@ class ListShortcodesCommand extends Command
PaginableRepositoryAdapter::ITEMS_PER_PAGE
),
1
)
->addOption(
'tags',
't',
InputOption::VALUE_NONE,
$this->translator->translate('Whether to display the tags or not')
);
}
public function execute(InputInterface $input, OutputInterface $output)
{
$page = intval($input->getOption('page'));
$showTags = $input->getOption('tags');
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
@@ -68,15 +76,31 @@ class ListShortcodesCommand extends Command
$result = $this->shortUrlService->listShortUrls($page);
$page++;
$table = new Table($output);
$table->setHeaders([
$headers = [
$this->translator->translate('Short code'),
$this->translator->translate('Original URL'),
$this->translator->translate('Date created'),
$this->translator->translate('Visits count'),
]);
];
if ($showTags) {
$headers[] = $this->translator->translate('Tags');
}
$table->setHeaders($headers);
foreach ($result as $row) {
$table->addRow(array_values($row->jsonSerialize()));
$shortUrl = $row->jsonSerialize();
if ($showTags) {
$shortUrl['tags'] = [];
foreach ($row->getTags() as $tag) {
$shortUrl['tags'][] = $tag->getName();
}
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
$table->addRow(array_values($shortUrl));
}
$table->render();

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

@@ -3,7 +3,9 @@ namespace Shlinkio\Shlink\CLI\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application as CliApp;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
@@ -25,9 +27,12 @@ class ApplicationFactory implements FactoryInterface
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['cli'];
$app = new CliApp('Shlink', '1.0.0');
$appOptions = $container->get(AppOptions::class);
$translator = $container->get(Translator::class);
$translator->setLocale($config['locale']);
$commands = isset($config['commands']) ? $config['commands'] : [];
$app = new CliApp($appOptions->getName(), $appOptions->getVersion());
foreach ($commands as $command) {
if (! $container->has($command)) {
continue;

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

@@ -0,0 +1,104 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Install;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Install\InstallCommand;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Process\Process;
use Zend\Config\Writer\WriterInterface;
class InstallCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
protected $configWriter;
public function setUp()
{
$processMock = $this->prophesize(Process::class);
$processMock->isSuccessful()->willReturn(true);
$processHelper = $this->prophesize(ProcessHelper::class);
$processHelper->getName()->willReturn('process');
$processHelper->setHelperSet(Argument::any())->willReturn(null);
$processHelper->run(Argument::cetera())->willReturn($processMock->reveal());
$app = new Application();
$helperSet = $app->getHelperSet();
$helperSet->set($processHelper->reveal());
$app->setHelperSet($helperSet);
$this->configWriter = $this->prophesize(WriterInterface::class);
$command = new InstallCommand($this->configWriter->reveal());
$app->add($command);
$questionHelper = $command->getHelper('question');
$questionHelper->setInputStream($this->createInputStream());
$this->commandTester = new CommandTester($command);
}
protected function createInputStream()
{
$stream = fopen('php://memory', 'r+', false);
fputs($stream, <<<CLI_INPUT
shlink_db
alejandro
1234
0
doma.in
abc123BCA
1
my_secret
CLI_INPUT
);
rewind($stream);
return $stream;
}
/**
* @test
*/
public function testInputIsProperlyParsed()
{
$this->configWriter->toFile(Argument::any(), [
'app_options' => [
'secret_key' => 'my_secret',
],
'entity_manager' => [
'connection' => [
'driver' => 'pdo_mysql',
'dbname' => 'shlink_db',
'user' => 'alejandro',
'password' => '1234',
],
],
'translator' => [
'locale' => 'en',
],
'cli' => [
'locale' => 'es',
],
'url_shortener' => [
'domain' => [
'schema' => 'http',
'hostname' => 'doma.in',
],
'shortcode_chars' => 'abc123BCA',
],
], false)->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shlink:install',
]);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Shortcode\GeneratePreviewCommand;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
use Zend\Paginator\Paginator;
class GeneratePreviewCommandTest extends TestCase
{
/**
* @var CommandTester
*/
protected $commandTester;
/**
* @var ObjectProphecy
*/
private $previewGenerator;
/**
* @var ObjectProphecy
*/
private $shortUrlService;
public function setUp()
{
$this->previewGenerator = $this->prophesize(PreviewGenerator::class);
$this->shortUrlService = $this->prophesize(ShortUrlService::class);
$command = new GeneratePreviewCommand(
$this->shortUrlService->reveal(),
$this->previewGenerator->reveal(),
Translator::factory([])
);
$app = new Application();
$app->add($command);
$this->commandTester = new CommandTester($command);
}
/**
* @test
*/
public function previewsForEveryUrlAreGenerated()
{
$paginator = $this->createPaginator([
(new ShortUrl())->setOriginalUrl('http://foo.com'),
(new ShortUrl())->setOriginalUrl('https://bar.com'),
(new ShortUrl())->setOriginalUrl('http://baz.com/something'),
]);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('http://foo.com')->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('https://bar.com')->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview('http://baz.com/something')->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:process-previews'
]);
}
/**
* @test
*/
public function exceptionWillOutputError()
{
$items = [
(new ShortUrl())->setOriginalUrl('http://foo.com'),
(new ShortUrl())->setOriginalUrl('https://bar.com'),
(new ShortUrl())->setOriginalUrl('http://baz.com/something'),
];
$paginator = $this->createPaginator($items);
$this->shortUrlService->listShortUrls(1)->willReturn($paginator)->shouldBeCalledTimes(1);
$this->previewGenerator->generatePreview(Argument::any())->willThrow(PreviewGenerationException::class)
->shouldBeCalledTimes(count($items));
$this->commandTester->execute([
'command' => 'shortcode:process-previews'
]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(count($items), substr_count($output, 'Error'));
}
protected function createPaginator(array $items)
{
$paginator = new Paginator(new ArrayAdapter($items));
$paginator->setItemCountPerPage(count($items));
return $paginator;
}
}

View File

@@ -1,10 +1,10 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
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;
@@ -39,8 +39,8 @@ class GenerateShortcodeCommandTest extends TestCase
*/
public function properShortCodeIsCreatedIfLongUrlIsCorrect()
{
$this->urlShortener->urlToShortCode(Argument::any())->willReturn('abc123')
->shouldBeCalledTimes(1);
$this->urlShortener->urlToShortCode(Argument::cetera())->willReturn('abc123')
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:generate',
@@ -55,8 +55,8 @@ class GenerateShortcodeCommandTest extends TestCase
*/
public function exceptionWhileParsingLongUrlOutputsError()
{
$this->urlShortener->urlToShortCode(Argument::any())->willThrow(new InvalidUrlException())
->shouldBeCalledTimes(1);
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:generate',

View File

@@ -1,10 +1,10 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
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

@@ -1,10 +1,10 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
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;
@@ -108,6 +108,23 @@ class ListShortcodesCommandTest extends TestCase
]);
}
/**
* @test
*/
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--tags' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Tags') > 0);
}
protected function getInputStream($inputData)
{
$stream = fopen('php://memory', 'r+', false);

View File

@@ -1,9 +1,9 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
namespace ShlinkioTest\Shlink\CLI\Command\Shortcode;
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

@@ -1,10 +1,10 @@
<?php
namespace ShlinkioTest\Shlink\CLI\Command;
namespace ShlinkioTest\Shlink\CLI\Command\Visit;
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,8 +3,10 @@ namespace ShlinkioTest\Shlink\CLI\Factory;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\ServiceManager;
class ApplicationFactoryTest extends TestCase
@@ -53,8 +55,10 @@ class ApplicationFactoryTest extends TestCase
{
return new ServiceManager(['services' => [
'config' => [
'cli' => $config,
'cli' => array_merge($config, ['locale' => 'en']),
],
AppOptions::class => new AppOptions(),
Translator::class => Translator::factory([]),
]]);
}
}

View File

@@ -2,36 +2,46 @@
use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory;
use Doctrine\Common\Cache\Cache;
use Doctrine\ORM\EntityManager;
use Shlinkio\Shlink\Common\ErrorHandler;
use Shlinkio\Shlink\Common\Factory\CacheFactory;
use Shlinkio\Shlink\Common\Factory\EntityManagerFactory;
use Shlinkio\Shlink\Common\Factory\TranslatorFactory;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Factory;
use Shlinkio\Shlink\Common\Image;
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Twig\Extension\TranslatorExtension;
use Symfony\Component\Filesystem\Filesystem;
use Zend\I18n\Translator\Translator;
use Zend\ServiceManager\Factory\InvokableFactory;
return [
'dependencies' => [
'invokables' => [
Filesystem::class => Filesystem::class,
],
'factories' => [
EntityManager::class => EntityManagerFactory::class,
EntityManager::class => Factory\EntityManagerFactory::class,
GuzzleHttp\Client::class => InvokableFactory::class,
Cache::class => CacheFactory::class,
IpLocationResolver::class => AnnotatedFactory::class,
Translator::class => TranslatorFactory::class,
Cache::class => Factory\CacheFactory::class,
'Logger_Shlink' => Factory\LoggerFactory::class,
Translator::class => Factory\TranslatorFactory::class,
TranslatorExtension::class => AnnotatedFactory::class,
LocaleMiddleware::class => AnnotatedFactory::class,
ErrorHandler\ContentBasedErrorHandler::class => AnnotatedFactory::class,
ErrorHandler\ErrorHandlerManager::class => ErrorHandler\ErrorHandlerManagerFactory::class,
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
Service\IpLocationResolver::class => AnnotatedFactory::class,
Service\PreviewGenerator::class => AnnotatedFactory::class,
],
'aliases' => [
'em' => EntityManager::class,
'httpClient' => GuzzleHttp\Client::class,
'translator' => Translator::class,
'logger' => LoggerInterface::class,
AnnotatedFactory::CACHE_SERVICE => Cache::class,
Logger::class => 'Logger_Shlink',
LoggerInterface::class => 'Logger_Shlink',
],
],

View File

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

View File

@@ -1,76 +0,0 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
class ContentBasedErrorHandler implements ErrorHandlerInterface
{
const DEFAULT_CONTENT = 'text/html';
/**
* @var ErrorHandlerManagerInterface
*/
private $errorHandlerManager;
/**
* ContentBasedErrorHandler constructor.
* @param ErrorHandlerManagerInterface|ErrorHandlerManager $errorHandlerManager
*
* @Inject({ErrorHandlerManager::class})
*/
public function __construct(ErrorHandlerManagerInterface $errorHandlerManager)
{
$this->errorHandlerManager = $errorHandlerManager;
}
/**
* Final handler for an application.
*
* @param Request $request
* @param Response $response
* @param null|mixed $err
* @return Response
*/
public function __invoke(Request $request, Response $response, $err = null)
{
// Try to get an error handler for provided request accepted type
$errorHandler = $this->resolveErrorHandlerFromAcceptHeader($request);
return $errorHandler($request, $response, $err);
}
/**
* Tries to resolve
*
* @param Request $request
* @return callable
*/
protected function resolveErrorHandlerFromAcceptHeader(Request $request)
{
// Try to find an error handler for one of the accepted content types
$accepts = $request->hasHeader('Accept') ? $request->getHeaderLine('Accept') : self::DEFAULT_CONTENT;
$accepts = explode(',', $accepts);
foreach ($accepts as $accept) {
if (! $this->errorHandlerManager->has($accept)) {
continue;
}
return $this->errorHandlerManager->get($accept);
}
// If it wasn't possible to find an error handler for accepted content type, use default one if registered
if ($this->errorHandlerManager->has(self::DEFAULT_CONTENT)) {
return $this->errorHandlerManager->get(self::DEFAULT_CONTENT);
}
// It wasn't possible to find an error handler
throw new InvalidArgumentException(sprintf(
'It wasn\'t possible to find an error handler for ["%s"] content types. '
. 'Make sure you have registered at least the default "%s" content type',
implode('", "', $accepts),
self::DEFAULT_CONTENT
));
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
interface ErrorHandlerInterface
{
/**
* Final handler for an application.
*
* @param Request $request
* @param Response $response
* @param null|mixed $err
* @return Response
*/
public function __invoke(Request $request, Response $response, $err = null);
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<?php
namespace Shlinkio\Shlink\Common\Exception;
class PreviewGenerationException extends RuntimeException
{
public static function fromImageError($error)
{
return new self(sprintf('Error generating a preview image with error: %s', $error));
}
}

View File

@@ -1,10 +1,10 @@
<?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 Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
@@ -12,8 +12,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,
];
/**
@@ -29,6 +32,19 @@ class CacheFactory implements FactoryInterface
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$appOptions = $container->get(AppOptions::class);
$adapter = $this->getAdapter($container);
$adapter->setNamespace($appOptions->__toString());
return $adapter;
}
/**
* @param ContainerInterface $container
* @return Cache\CacheProvider
*/
protected function getAdapter(ContainerInterface $container)
{
// Try to get the adapter from config
$config = $container->get('config');
@@ -36,10 +52,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\CacheProvider
*/
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,10 @@
<?php
namespace Shlinkio\Shlink\Common\Image;
use mikehaertl\wkhtmlto\Image;
use Zend\ServiceManager\AbstractPluginManager;
class ImageBuilder extends AbstractPluginManager implements ImageBuilderInterface
{
protected $instanceOf = Image::class;
}

View File

@@ -1,13 +1,14 @@
<?php
namespace Shlinkio\Shlink\Common\ErrorHandler;
namespace Shlinkio\Shlink\Common\Image;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use mikehaertl\wkhtmlto\Image;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class ErrorHandlerManagerFactory implements FactoryInterface
class ImageBuilderFactory implements FactoryInterface
{
/**
* Create an object
@@ -23,8 +24,8 @@ class ErrorHandlerManagerFactory implements FactoryInterface
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['error_handler'];
$plugins = isset($config['plugins']) ? $config['plugins'] : [];
return new ErrorHandlerManager($container, $plugins);
return new ImageBuilder($container, ['factories' => [
Image::class => ImageFactory::class,
]]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Shlinkio\Shlink\Common\Image;
use Zend\ServiceManager\ServiceLocatorInterface;
interface ImageBuilderInterface extends ServiceLocatorInterface
{
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Shlinkio\Shlink\Common\Image;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use mikehaertl\wkhtmlto\Image;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class ImageFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->get('config')['phpwkhtmltopdf'];
$image = new Image(isset($config['images']) ? $config['images'] : null);
if (isset($options) && isset($options['url'])) {
$image->setPage($options['url']);
}
return $image;
}
}

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

@@ -0,0 +1,70 @@
<?php
namespace Shlinkio\Shlink\Common\Service;
use Acelaya\ZsmAnnotatedServices\Annotation\Inject;
use mikehaertl\wkhtmlto\Image;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Image\ImageBuilder;
use Shlinkio\Shlink\Common\Image\ImageBuilderInterface;
use Symfony\Component\Filesystem\Filesystem;
class PreviewGenerator implements PreviewGeneratorInterface
{
/**
* @var string
*/
private $location;
/**
* @var ImageBuilderInterface
*/
private $imageBuilder;
/**
* @var Filesystem
*/
private $filesystem;
/**
* PreviewGenerator constructor.
* @param ImageBuilderInterface $imageBuilder
* @param Filesystem $filesystem
* @param string $location
*
* @Inject({ImageBuilder::class, Filesystem::class, "config.preview_generation.files_location"})
*/
public function __construct(ImageBuilderInterface $imageBuilder, Filesystem $filesystem, $location)
{
$this->location = $location;
$this->imageBuilder = $imageBuilder;
$this->filesystem = $filesystem;
}
/**
* Generates and stores preview for provided website and returns the path to the image file
*
* @param string $url
* @return string
* @throws PreviewGenerationException
*/
public function generatePreview($url)
{
/** @var Image $image */
$image = $this->imageBuilder->build(Image::class, ['url' => $url]);
// If the file already exists, return its path
$cacheId = sprintf('preview_%s.%s', urlencode($url), $image->type);
$path = $this->location . '/' . $cacheId;
if ($this->filesystem->exists($path)) {
return $path;
}
// Save and check if an error occurred
$image->saveAs($path);
$error = $image->getError();
if (! empty($error)) {
throw PreviewGenerationException::fromImageError($error);
}
// Cache the path and return it
return $path;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Shlinkio\Shlink\Common\Service;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
interface PreviewGeneratorInterface
{
/**
* Generates and stores preview for provided website and returns the path to the image file
*
* @param string $url
* @return string
* @throws PreviewGenerationException
*/
public function generatePreview($url);
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Shlinkio\Shlink\Common\Util;
use Zend\Diactoros\Response;
use Zend\Diactoros\Stream;
use Zend\Stdlib\ArrayUtils;
trait ResponseUtilsTrait
{
protected function generateDownloadFileResponse($filePath)
{
return $this->generateBinaryResponse($filePath, [
'Content-Disposition' => 'attachment; filename=' . basename($filePath),
'Content-Transfer-Encoding' => 'Binary',
'Content-Description' => 'File Transfer',
'Pragma' => 'public',
'Expires' => '0',
'Cache-Control' => 'must-revalidate',
]);
}
protected function generateImageResponse($imagePath)
{
return $this->generateBinaryResponse($imagePath);
}
protected function generateBinaryResponse($path, $extraHeaders = [])
{
$body = new Stream($path);
return new Response($body, 200, ArrayUtils::merge([
'Content-Type' => (new \finfo(FILEINFO_MIME))->file($path),
'Content-Length' => (string) $body->getSize(),
], $extraHeaders));
}
}

View File

@@ -23,7 +23,6 @@ class ConfigProviderTest extends TestCase
{
$config = $this->configProvider->__invoke();
$this->assertArrayHasKey('error_handler', $config);
$this->assertArrayHasKey('middleware_pipeline', $config);
$this->assertArrayHasKey('dependencies', $config);
$this->assertArrayHasKey('twig', $config);

View File

@@ -1,75 +0,0 @@
<?php
namespace ShlinkioTest\Shlink\Common\ErrorHandler;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\ErrorHandler\ContentBasedErrorHandler;
use Shlinkio\Shlink\Common\ErrorHandler\ErrorHandlerManager;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\ServiceManager\ServiceManager;
class ContentBasedErrorHandlerTest extends TestCase
{
/**
* @var ContentBasedErrorHandler
*/
protected $errorHandler;
public function setUp()
{
$this->errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), [
'factories' => [
'text/html' => [$this, 'factory'],
'application/json' => [$this, 'factory'],
],
]));
}
public function factory($container, $name)
{
return function () use ($name) {
return $name;
};
}
/**
* @test
*/
public function correctAcceptHeaderValueInvokesErrorHandler()
{
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,application/json');
$result = $this->errorHandler->__invoke($request, new Response());
$this->assertEquals('application/json', $result);
}
/**
* @test
*/
public function defaultContentTypeIsUsedWhenNoAcceptHeaderisPresent()
{
$request = ServerRequestFactory::fromGlobals();
$result = $this->errorHandler->__invoke($request, new Response());
$this->assertEquals('text/html', $result);
}
/**
* @test
*/
public function defaultContentTypeIsUsedWhenAcceptedContentIsNotSupported()
{
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml');
$result = $this->errorHandler->__invoke($request, new Response());
$this->assertEquals('text/html', $result);
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\InvalidArgumentException
*/
public function ifNoErrorHandlerIsFoundAnExceptionIsThrown()
{
$this->errorHandler = new ContentBasedErrorHandler(new ErrorHandlerManager(new ServiceManager(), []));
$request = ServerRequestFactory::fromGlobals()->withHeader('Accept', 'foo/bar,text/xml');
$result = $this->errorHandler->__invoke($request, new Response());
}
}

View File

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

View File

@@ -1,45 +0,0 @@
<?php
namespace ShlinkioTest\Shlink\Common\ErrorHandler;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\ErrorHandler\ErrorHandlerManager;
use Zend\ServiceManager\ServiceManager;
class ErrorHandlerManagerTest extends TestCase
{
/**
* @var ErrorHandlerManager
*/
protected $pluginManager;
public function setUp()
{
$this->pluginManager = new ErrorHandlerManager(new ServiceManager(), [
'services' => [
'foo' => function () {
},
],
'invokables' => [
'invalid' => \stdClass::class,
]
]);
}
/**
* @test
*/
public function callablesAreReturned()
{
$instance = $this->pluginManager->get('foo');
$this->assertInstanceOf(\Closure::class, $instance);
}
/**
* @test
* @expectedException \Zend\ServiceManager\Exception\InvalidServiceException
*/
public function nonCallablesThrowException()
{
$this->pluginManager->get('invalid');
}
}

View File

@@ -4,8 +4,11 @@ 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 Shlinkio\Shlink\Core\Options\AppOptions;
use Zend\ServiceManager\ServiceManager;
class CacheFactoryTest extends TestCase
@@ -61,16 +64,53 @@ 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,
],
] : [],
AppOptions::class => new AppOptions(),
]]);
}
}

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,29 @@
<?php
namespace ShlinkioTest\Shlink\Common\Image;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Image\ImageBuilder;
use Shlinkio\Shlink\Common\Image\ImageBuilderFactory;
use Zend\ServiceManager\ServiceManager;
class ImageBuilderFactoryTest extends TestCase
{
/**
* @var ImageBuilderFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new ImageBuilderFactory();
}
/**
* @test
*/
public function serviceIsCreated()
{
$instance = $this->factory->__invoke(new ServiceManager(), '');
$this->assertInstanceOf(ImageBuilder::class, $instance);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace ShlinkioTest\Shlink\Common\Image;
use mikehaertl\wkhtmlto\Image;
use PHPUnit_Framework_TestCase as TestCase;
use Shlinkio\Shlink\Common\Image\ImageFactory;
use Zend\ServiceManager\ServiceManager;
class ImageFactoryTest extends TestCase
{
/**
* @var ImageFactory
*/
protected $factory;
public function setUp()
{
$this->factory = new ImageFactory();
}
/**
* @test
*/
public function noPageIsSetWhenOptionsAreNotProvided()
{
/** @var Image $image */
$image = $this->factory->__invoke(new ServiceManager(['services' => [
'config' => ['phpwkhtmltopdf' => []],
]]), '');
$this->assertInstanceOf(Image::class, $image);
$ref = new \ReflectionObject($image);
$page = $ref->getProperty('_page');
$page->setAccessible(true);
$this->assertNull($page->getValue($image));
}
/**
* @test
*/
public function aPageIsSetWhenOptionsIncludeTheUrl()
{
$expectedPage = 'foo/bar.html';
/** @var Image $image */
$image = $this->factory->__invoke(new ServiceManager(['services' => [
'config' => ['phpwkhtmltopdf' => []],
]]), '', ['url' => $expectedPage]);
$this->assertInstanceOf(Image::class, $image);
$ref = new \ReflectionObject($image);
$page = $ref->getProperty('_page');
$page->setAccessible(true);
$this->assertEquals($expectedPage, $page->getValue($image));
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace ShlinkioTest\Shlink\Common\Service;
use mikehaertl\wkhtmlto\Image;
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Image\ImageBuilder;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\ServiceManager;
class PreviewGeneratorTest extends TestCase
{
/**
* @var PreviewGenerator
*/
protected $generator;
/**
* @var ObjectProphecy
*/
protected $image;
/**
* @var ObjectProphecy
*/
protected $filesystem;
public function setUp()
{
$this->image = $this->prophesize(Image::class);
$this->filesystem = $this->prophesize(Filesystem::class);
$this->generator = new PreviewGenerator(new ImageBuilder(new ServiceManager(), [
'factories' => [
Image::class => function () {
return $this->image->reveal();
},
]
]), $this->filesystem->reveal(), 'dir');
}
/**
* @test
*/
public function alreadyProcessedElementsAreNotProcessed()
{
$url = 'http://foo.com';
$this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(true)
->shouldBeCalledTimes(1);
$this->image->saveAs(Argument::cetera())->shouldBeCalledTimes(0);
$this->assertEquals(sprintf('dir/preview_%s.png', urlencode($url)), $this->generator->generatePreview($url));
}
/**
* @test
*/
public function nonProcessedElementsAreProcessed()
{
$url = 'http://foo.com';
$cacheId = sprintf('preview_%s.png', urlencode($url));
$expectedPath = 'dir/' . $cacheId;
$this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false)
->shouldBeCalledTimes(1);
$this->image->saveAs($expectedPath)->shouldBeCalledTimes(1);
$this->image->getError()->willReturn('')->shouldBeCalledTimes(1);
$this->assertEquals($expectedPath, $this->generator->generatePreview($url));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\PreviewGenerationException
*/
public function errorWhileGeneratingPreviewThrowsException()
{
$url = 'http://foo.com';
$cacheId = sprintf('preview_%s.png', urlencode($url));
$expectedPath = 'dir/' . $cacheId;
$this->filesystem->exists(sprintf('dir/preview_%s.png', urlencode($url)))->willReturn(false)
->shouldBeCalledTimes(1);
$this->image->saveAs($expectedPath)->shouldBeCalledTimes(1);
$this->image->getError()->willReturn('Error!!')->shouldBeCalledTimes(1);
$this->generator->generatePreview($url);
}
}

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;
use Shlinkio\Shlink\Core\Service;
return [
'dependencies' => [
'factories' => [
Options\AppOptions::class => Options\AppOptionsFactory::class,
// Services
Service\UrlShortener::class => AnnotatedFactory::class,
Service\VisitsTracker::class => AnnotatedFactory::class,
@@ -14,7 +18,10 @@ return [
Service\VisitService::class => AnnotatedFactory::class,
// Middleware
RedirectAction::class => AnnotatedFactory::class,
Action\RedirectAction::class => AnnotatedFactory::class,
Action\QrCodeAction::class => AnnotatedFactory::class,
Action\PreviewAction::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,33 @@ return [
[
'name' => 'long-url-redirect',
'path' => '/{shortCode}',
'middleware' => RedirectAction::class,
'middleware' => Action\RedirectAction::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'short-url-qr-code',
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
'middleware' => [
Middleware\QrCodeCacheMiddleware::class,
Action\QrCodeAction::class,
],
'allowed_methods' => ['GET'],
],
[
'name' => 'short-url-preview',
'path' => '/{shortCode}/preview',
'middleware' => Action\PreviewAction::class,
'allowed_methods' => ['GET'],
],
// Old QR code route. Deprecated
[
'name' => 'short-url-qr-code-old',
'path' => '/qr/{shortCode}[/{size:[0-9]+}]',
'middleware' => [
Middleware\QrCodeCacheMiddleware::class,
Action\QrCodeAction::class,
],
'allowed_methods' => ['GET'],
],
],

Binary file not shown.

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2016-07-21 16:50+0200\n"
"PO-Revision-Date: 2016-07-21 16:51+0200\n"
"Last-Translator: \n"
"POT-Creation-Date: 2016-08-21 18:17+0200\n"
"PO-Revision-Date: 2016-08-21 18:17+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"

View File

@@ -0,0 +1,85 @@
<?php
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 Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Common\Util\ResponseUtilsTrait;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Stratigility\MiddlewareInterface;
class PreviewAction implements MiddlewareInterface
{
use ResponseUtilsTrait;
/**
* @var PreviewGeneratorInterface
*/
private $previewGenerator;
/**
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* PreviewAction constructor.
* @param PreviewGeneratorInterface $previewGenerator
* @param UrlShortenerInterface $urlShortener
*
* @Inject({PreviewGenerator::class, UrlShortener::class})
*/
public function __construct(PreviewGeneratorInterface $previewGenerator, UrlShortenerInterface $urlShortener)
{
$this->previewGenerator = $previewGenerator;
$this->urlShortener = $urlShortener;
}
/**
* Process an incoming request and/or response.
*
* Accepts a server-side request and a response instance, and does
* something with them.
*
* If the response is not complete and/or further processing would not
* interfere with the work done in the middleware, or if the middleware
* wants to delegate to another process, it can use the `$out` callable
* if present.
*
* If the middleware does not return a value, execution of the current
* request is considered complete, and the response instance provided will
* be considered the response to return.
*
* Alternately, the middleware may return a response instance.
*
* Often, middleware will `return $out();`, with the assumption that a
* later middleware will return a response.
*
* @param Request $request
* @param Response $response
* @param null|callable $out
* @return null|Response
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$shortCode = $request->getAttribute('shortCode');
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode);
if (! isset($url)) {
return $out($request, $response->withStatus(404), 'Not found');
}
$imagePath = $this->previewGenerator->generatePreview($url);
return $this->generateImageResponse($imagePath);
} catch (InvalidShortCodeException $e) {
return $out($request, $response->withStatus(404), 'Not found');
} catch (PreviewGenerationException $e) {
return $out($request, $response->withStatus(500), 'Preview generation error');
}
}
}

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

@@ -28,8 +28,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
* type="string",
* nullable=false,
* length=10,
* unique=true,
* options={"collation": "utf8_bin"}
* unique=true
* )
*/
protected $shortCode;
@@ -43,6 +42,16 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
* @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY")
*/
protected $visits;
/**
* @var Collection|Tag[]
* @ORM\ManyToMany(targetEntity=Tag::class, cascade={"persist"})
* @ORM\JoinTable(name="short_urls_in_tags", joinColumns={
* @ORM\JoinColumn(name="short_url_id", referencedColumnName="id")
* }, inverseJoinColumns={
* @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
* })
*/
protected $tags;
/**
* ShortUrl constructor.
@@ -52,6 +61,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
$this->setDateCreated(new \DateTime());
$this->setVisits(new ArrayCollection());
$this->setShortCode('');
$this->tags = new ArrayCollection();
}
/**
@@ -126,6 +136,34 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
return $this;
}
/**
* @return Collection|Tag[]
*/
public function getTags()
{
return $this->tags;
}
/**
* @param Collection|Tag[] $tags
* @return $this
*/
public function setTags($tags)
{
$this->tags = $tags;
return $this;
}
/**
* @param Tag $tag
* @return $this
*/
public function addTag(Tag $tag)
{
$this->tags->add($tag);
return $this;
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
@@ -140,6 +178,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
'originalUrl' => $this->originalUrl,
'dateCreated' => isset($this->dateCreated) ? $this->dateCreated->format(\DateTime::ISO8601) : null,
'visitsCount' => count($this->visits),
'tags' => $this->tags->toArray(),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
/**
* Class Tag
* @author
* @link
*
* @ORM\Entity()
* @ORM\Table(name="tags")
*/
class Tag extends AbstractEntity implements \JsonSerializable
{
/**
* @var string
* @ORM\Column(unique=true)
*/
protected $name;
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param string $name
* @return $this
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
{
return $this->name;
}
}

View File

@@ -5,7 +5,7 @@ use Shlinkio\Shlink\Common\Exception\RuntimeException;
class InvalidShortCodeException extends RuntimeException
{
public static function fromShortCode($shortCode, $charSet, \Exception $previous = null)
public static function fromCharset($shortCode, $charSet, \Exception $previous = null)
{
$code = isset($previous) ? $previous->getCode() : -1;
return new static(
@@ -14,4 +14,9 @@ class InvalidShortCodeException extends RuntimeException
$previous
);
}
public static function fromNotFoundShortCode($shortCode)
{
return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
}
}

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