Compare commits

..

35 Commits

Author SHA1 Message Date
Alejandro Celaya
8966cf9910 Merge pull request #323 from acelaya/feature/docker-build
Feature/docker build
2018-12-16 19:40:32 +01:00
Alejandro Celaya
4eb4df9ca2 Updated changelog 2018-12-16 13:19:44 +01:00
Alejandro Celaya
32861b1c72 Added new travis deployment which will build the docker image 2018-12-16 13:19:17 +01:00
Alejandro Celaya
7248ca2e9b Merge pull request #324 from acelaya/feature/entities-config
Moved entities mappings from annotations to external config files
2018-12-16 13:18:19 +01:00
Alejandro Celaya
a6ec93f883 Updated changelog 2018-12-16 12:14:13 +01:00
Alejandro Celaya
a28c1d17c5 Moved entities mappings from annotations to external config files 2018-12-16 12:08:03 +01:00
Alejandro Celaya
fb705b44a4 Merge pull request #318 from acelaya/feature/document-non-rest
Feature/document non rest
2018-12-09 16:35:59 +01:00
Alejandro Celaya
a32bab9fd0 Updated changelog 2018-12-09 16:25:24 +01:00
Alejandro Celaya
6396e7f964 Added other of non-rest endpoints 2018-12-09 15:43:56 +01:00
Alejandro Celaya
c898cef277 Documented first non-rest endpoint 2018-12-09 15:18:10 +01:00
Alejandro Celaya
baeba54b06 Merge branch 'master' of github.com:shlinkio/shlink 2018-12-09 14:22:40 +01:00
Alejandro Celaya
f5ee5bf7fb Documented that swoole server needs to be restarted when it is being used ot serve shlink 2018-12-09 14:22:21 +01:00
Alejandro Celaya
73605414f9 Merge pull request #316 from acelaya/feature/symfony-42
Feature/symfony 42
2018-12-08 14:30:20 +01:00
Alejandro Celaya
6045c371e1 Updated changelog 2018-12-08 14:12:50 +01:00
Alejandro Celaya
97a9289d5f Created ShlinkTableTest 2018-12-08 14:11:14 +01:00
Alejandro Celaya
1983fc9b67 Added current page message in list short urls CLI command 2018-12-08 12:16:39 +01:00
Alejandro Celaya
bb40d84212 Used ShlinkTable on every location rendering a CLI table 2018-12-08 12:12:11 +01:00
Alejandro Celaya
46a35c553e Created class to wrap CLI table rendering behavior 2018-12-08 11:32:16 +01:00
Alejandro Celaya
080943e810 Updated how Symfony commands are used to fulfill API from v4.2 2018-12-08 10:34:04 +01:00
Alejandro Celaya
62fb3863c6 Merge pull request #315 from acelaya/feature/config
Updated how config is imported and merged, so that it includes any co…
2018-12-07 20:56:57 +01:00
Alejandro Celaya
2db03a163d Updated how config is imported and merged, so that it includes any config file in json format from config/params dir 2018-12-07 20:48:20 +01:00
Alejandro Celaya
9e3dd82efe Merge pull request #314 from acelaya/feature/fix-context
Feature/fix context
2018-12-07 20:30:53 +01:00
Alejandro Celaya
9f1989bfef Updated changelog 2018-12-07 19:49:17 +01:00
Alejandro Celaya
c0bdd8fc77 Removed concept of execution context and piped CloseDbConnectionMiddleware always 2018-12-07 19:46:46 +01:00
Alejandro Celaya
8a23c90e46 Merge pull request #313 from acelaya/feature/favicon
Feature/favicon
2018-12-07 09:45:55 +01:00
Alejandro Celaya
9095e5b057 Enabled static files with swoole, otherwise, robots.txt and favicon.ico are never served when running shlink with swoole 2018-12-07 09:38:07 +01:00
Alejandro Celaya
52c18115af Updated changelog 2018-12-07 09:18:56 +01:00
Alejandro Celaya
737137b19f Added favicon 2018-12-07 09:17:31 +01:00
Alejandro Celaya
7b78bee135 Merge pull request #311 from acelaya/feature/improvements
Feature/improvements
2018-12-07 02:55:27 +01:00
Alejandro Celaya
accda36a7b Updated default secret_key value 2018-12-07 02:49:50 +01:00
Alejandro Celaya
69dd9eb067 Updated readme mentioning docker image 2018-12-07 02:41:06 +01:00
Alejandro Celaya
a562bc661d Improved CacheFactory class 2018-12-06 21:05:11 +01:00
Alejandro Celaya
258f12f684 Merge pull request #303 from acelaya/feature/expressive-swoole-2.2
Feature/expressive swoole 2.2
2018-12-05 21:46:48 +01:00
Alejandro Celaya
4dc8d77a5a Updated changelog 2018-12-05 21:29:16 +01:00
Alejandro Celaya
7c5825d1bc Removed custom AccessLogFactory by updating to zend-expressive-swoole 2.2 2018-12-05 21:26:19 +01:00
54 changed files with 874 additions and 583 deletions

2
.gitignore vendored
View File

@@ -6,5 +6,5 @@ vendor/
.env
data/database.sqlite
data/GeoLite2-City.mmdb
docs/swagger-ui
docs/swagger-ui*
docker-compose.override.yml

View File

@@ -41,11 +41,17 @@ before_deploy:
- ./build.sh ${TRAVIS_TAG#?}
deploy:
provider: releases
api_key:
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
php: 7.1
- provider: releases
api_key:
secure: a9dbZchocqeuOViwUeNH54bQR5Sz7rEYXx5b9WPFtnFn9LGKKUaLbA2U91UQ9QKPrcTpsALubUYbw2CnNmvCwzaY+R8lCD3gkU4ohsEnbpnw3deOeixI74sqBHJAuCH9FSaRDGILoBMtUKx2xlzIymFxkIsgIukkGbdkWHDlRWY3oTUUuw1SQ2Xk9KDsbJQtjIc1+G/O6gHaV4qv/R9W8NPmJExKTNDrAZbC1vIUnxqp4UpVo1hst8qPd1at94CndDYM5rG+7imGbdtxTxzamt819qdTO1OfvtctKawNAm7YXZrrWft6c7gI6j6SI4hxd+ZrrPBqbaRFHkZHjnNssO/yn4SaOHFFzccmu0MzvpPCf0qWZwd3sGHVYer1MnR2mHYqU84QPlW3nrHwJjkrpq3+q0JcBY6GsJs+RskHNtkMTKV05Iz6QUI5YZGwTpuXaRm036SmavjGc4IDlMaYCk/NmbB9BKpthJxLdUpczOHpnjXXHziotWD6cfEnbjU3byfD8HY5WrxSjsNT7SKmXN3hRof7bk985ewQVjGT42O3NbnfnqjQQWr/B7/zFTpLR4f526Bkq12CdCyf5lvrbq+POkLVdJ+uFfR7ds248Ue/jBQy6kM1tWmKF9QiwisFlA84eQ4CW3I93Rp97URv+AQa9zmbD0Ve3Udp+g6nF5I=
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
php: 7.1
- provider: script
script: bash data/travis/trigger_docker_build.sh
skip_cleanup: true
on:
tags: true
php: 7.1

View File

@@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## 1.15.1 - 2018-12-16
#### Added
* [#162](https://github.com/shlinkio/shlink/issues/162) Added non-rest endpoints to swagger definition.
#### Changed
* [#312](https://github.com/shlinkio/shlink/issues/312) Now all config files both in `php` and `json` format are loaded from `config/params` folder, easing users to provided customizations to docker image.
* [#226](https://github.com/shlinkio/shlink/issues/226) Updated how table are rendered in CLI commands, making use of new features in Symfony 4.2.
* [#321](https://github.com/shlinkio/shlink/issues/321) Extracted entities mappings from entities to external config files.
* [#308](https://github.com/shlinkio/shlink/issues/308) Automated docker image building.
#### Deprecated
* *Nothing*
#### Removed
* [#301](https://github.com/shlinkio/shlink/issues/301) Removed custom `AccessLogFactory` in favor of the implementation included in [zendframework/zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) v2.2.0
#### Fixed
* [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser.
* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddlware` to be always piped. Now the check is not even made, which simplifies everything.
## 1.15.0 - 2018-12-02
#### Added

View File

@@ -202,11 +202,12 @@ In future versions, it is planed that, when using **swoole** to serve shlink, so
When a new Shlink version is available, you don't need to repeat the entire process yourself. Instead, follow these steps:
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`)
2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`)
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`).
2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`).
3. Run the `bin/update` script in the new version's directory to migrate your configuration over.
4. If you are using shlink with swoole, restart the service by running `/etc/init.d/shlink_swoole restart`.
The script will ask you for the location from previous shlink version, and use it in order to import the configuration. It will then update the database and generate some the assets neccessary for Shlink to function.
The `bin/update` script will ask you for the location from previous shlink version, and use it in order to import the configuration. It will then update the database and generate some assets shlink needs to work.
Right now, it does not import cached info (like website previews), but it will. For now you will need to regenerate them again.
@@ -214,11 +215,9 @@ Right now, it does not import cached info (like website previews), but it will.
## Using a docker image
Currently there's no official docker image, but there's a work in progress alpha version you can find [here](https://hub.docker.com/r/shlinkio/shlink/).
Starting with version 1.15.0, an official docker image is provided. You can find the docs on how to use it [here](https://hub.docker.com/r/shlinkio/shlink/).
The idea will be that you can just generate a container using the image and provide predefined config files via volumes or CLI arguments, so that you get shlink up and running.
Currently the image does not expose an entry point which let's you interact with shlink's CLI interface, nor allows configuration to be passed.
The idea is that you can just generate a container using the image and provide custom config via env vars.
## Using shlink

View File

@@ -3,11 +3,8 @@
declare(strict_types=1);
use Interop\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Exec\ExecutionContext;
use Symfony\Component\Console\Application as CliApp;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
putenv(sprintf('CURRENT_SHLINK_CONTEXT=%s', ExecutionContext::CLI));
$container->get(CliApp::class)->run();

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -e
if [ "$#" -ne 1 ]; then
if [[ "$#" -ne 1 ]]; then
echo "Usage:" >&2
echo " $0 {version}" >&2
exit 1
@@ -10,7 +10,7 @@ fi
version=$1
builtcontent="./build/shlink_${version}_dist"
projectdir=$(pwd)
[ -f ./composer.phar ] && composerBin='./composer.phar' || composerBin='composer'
[[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
# Copy project content to temp dir
echo 'Copying project files...'
@@ -18,6 +18,7 @@ rm -rf "${builtcontent}"
mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
--exclude=data/infra \
--exclude=data/travis \
--exclude=data/migrations_template.txt \
--exclude=data/GeoLite2-City.mmdb \
--exclude=**/.gitignore \

View File

@@ -30,10 +30,10 @@
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"symfony/console": "^4.1",
"symfony/filesystem": "^4.1",
"symfony/lock": "^4.1",
"symfony/process": "^4.1",
"symfony/console": "^4.2",
"symfony/filesystem": "^4.2",
"symfony/lock": "^4.2",
"symfony/process": "^4.2",
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
@@ -42,7 +42,7 @@
"zendframework/zend-expressive-fastroute": "^3.0",
"zendframework/zend-expressive-helpers": "^5.0",
"zendframework/zend-expressive-platesrenderer": "^2.0",
"zendframework/zend-expressive-swoole": "^2.1",
"zendframework/zend-expressive-swoole": "^2.2",
"zendframework/zend-i18n": "^2.7",
"zendframework/zend-inputfilter": "^2.8",
"zendframework/zend-paginator": "^2.6",
@@ -57,8 +57,8 @@
"phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.3",
"shlinkio/php-coding-standard": "~1.0.0",
"symfony/dotenv": "^4.0",
"symfony/var-dumper": "^4.0",
"symfony/dotenv": "^4.2",
"symfony/var-dumper": "^4.2",
"zendframework/zend-component-installer": "^2.1",
"zendframework/zend-expressive-tooling": "^1.0"
},

View File

@@ -8,7 +8,7 @@ return [
'app_options' => [
'name' => 'Shlink',
'version' => '%SHLINK_VERSION%',
'secret_key' => env('SECRET_KEY'),
'secret_key' => env('SECRET_KEY', ''),
'disable_track_param' => null,
],

View File

@@ -7,7 +7,6 @@ use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor;
use Zend\Expressive\Swoole\Log\AccessLogInterface;
use const PHP_EOL;
return [
@@ -32,7 +31,6 @@ return [
'class' => StreamHandler::class,
'level' => Logger::INFO,
'stream' => 'php://stdout',
'formatter' => 'dashed',
],
],
@@ -61,15 +59,13 @@ return [
'factories' => [
'Logger_Shlink' => Common\Factory\LoggerFactory::class,
'Logger_Swoole' => Common\Factory\LoggerFactory::class,
AccessLogInterface::class => Common\Logger\Swoole\AccessLogFactory::class,
],
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'logger' => [
'logger_name' => 'Logger_Swoole',
'logger-name' => 'Logger_Swoole',
],
],
],

View File

@@ -10,18 +10,11 @@ return [
'middleware_pipeline' => [
'pre-routing' => [
'middleware' => (function () {
$middleware = [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
];
if (Common\Exec\ExecutionContext::currentContextIsSwoole()) {
$middleware[] = Common\Middleware\CloseDbConnectionMiddleware::class;
}
return $middleware;
})(),
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
Common\Middleware\CloseDbConnectionMiddleware::class,
],
'priority' => 12,
],
'pre-routing-rest' => [

View File

@@ -9,9 +9,6 @@ return [
'swoole-http-server' => [
'host' => '0.0.0.0',
'process-name' => 'shlink',
'static-files' => [
'enable' => false,
],
],
],

View File

@@ -19,5 +19,6 @@ return (new ConfigAggregator\ConfigAggregator([
CLI\ConfigProvider::class,
Installer\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\ZendConfigProvider('config/{autoload/{{,*.}global,{,*.}local},params/generated_config}.php'),
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
], 'data/cache/app_config.php'))->getMergedConfig();

View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -e
# Get latest commit in master, in plain text
LATEST_MASTER_COMMIT=$(curl -H "Accept: application/vnd.github.sha" -X GET https://api.github.com/repos/shlinkio/shlink-docker-image/commits/master)
# Create new tag and a ref to the tag, which will trigger image build on it
curl -u acelaya:${GITHUB_OAUTH_KEY} \
-H "Content-Type: application/json" \
--data "{ \"tag\": \"${TRAVIS_TAG}\", \"message\": \"${TRAVIS_TAG}\", \"object\": \"${LATEST_MASTER_COMMIT}\", \"type\": \"commit\" }" \
-X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/tags
curl -u acelaya:${GITHUB_OAUTH_KEY} \
-H "Content-Type: application/json" \
--data "{ \"ref\": \"refs/tags/${TRAVIS_TAG}\", \"sha\": \"${LATEST_MASTER_COMMIT}\" }" \
-X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/refs
# Trigger image build for "latest
curl -H "Content-Type: application/json" \
--data '{ "docker_tag": "latest" }' \
-X POST https://registry.hub.docker.com/u/shlinkio/shlink/trigger/${DOCKER_TRIGGER_TOKEN}/

View File

@@ -0,0 +1,36 @@
{
"get": {
"operationId": "shortUrl",
"tags": [
"URL Shortener"
],
"summary": "Short URL",
"description": "Represents a short URL. Tracks the visit and redirects tio the corresponding long URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"302": {
"description": "Visit properly tracked and redirected"
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
{
"get": {
"operationId": "shortUrlPreview",
"tags": [
"URL Shortener"
],
"summary": "Short URL preview image",
"description": "Returns the preview of the page behind a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Image in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,56 @@
{
"get": {
"operationId": "shortUrlQrCode",
"tags": [
"URL Shortener"
],
"summary": "Short URL QR code",
"description": "Generates a QR code image pointing to a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "size",
"in": "path",
"description": "The size of the image to be returned.",
"required": false,
"schema": {
"type": "integer",
"minimum": 50,
"maximum": 1000,
"default": 300
}
}
],
"responses": {
"200": {
"description": "QR code in PNG format",
"content": {
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,44 @@
{
"get": {
"operationId": "trackShortUrl",
"tags": [
"URL Shortener"
],
"summary": "Short URL tracking pixel",
"description": "Generates a 1px transparent image which can be used to track emails with a short URL",
"parameters": [
{
"name": "shortCode",
"in": "path",
"description": "The short code to resolve.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Image in GIF format",
"content": {
"image/gif": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -6,15 +6,20 @@
"version": "1.0"
},
"externalDocs": {
"url": "https://shlink.io/api-docs",
"description": "Find more info on how to start using this API here"
},
"servers": [
{
"url": "{schema}://{server}/rest",
"url": "{scheme}://{host}",
"variables": {
"schema": {
"scheme": {
"default": "https",
"enum": ["https", "http"]
},
"server": {
"host": {
"default": ""
}
}
@@ -51,35 +56,52 @@
"name": "Visits",
"description": "Operations to manage visits on short URLs"
},
{
"name": "URL Shortener",
"description": "Non-rest endpoints, used to be publicly exposed"
},
{
"name": "Authentication",
"description": "Authentication-related endpoints"
"description": "**[Deprecated]** Authentication-related endpoints"
}
],
"paths": {
"/v1/short-urls": {
"/rest/v1/short-urls": {
"$ref": "paths/v1_short-urls.json"
},
"/v1/short-urls/shorten": {
"/rest/v1/short-urls/shorten": {
"$ref": "paths/v1_short-urls_shorten.json"
},
"/v1/short-urls/{shortCode}": {
"/rest/v1/short-urls/{shortCode}": {
"$ref": "paths/v1_short-urls_{shortCode}.json"
},
"/v1/short-urls/{shortCode}/tags": {
"/rest/v1/short-urls/{shortCode}/tags": {
"$ref": "paths/v1_short-urls_{shortCode}_tags.json"
},
"/v1/tags": {
"/rest/v1/tags": {
"$ref": "paths/v1_tags.json"
},
"/v1/short-urls/{shortCode}/visits": {
"/rest/v1/short-urls/{shortCode}/visits": {
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
},
"/v1/authenticate": {
"/{shortCode}": {
"$ref": "paths/{shortCode}.json"
},
"/{shortCode}/track": {
"$ref": "paths/{shortCode}_track.json"
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
},
"/{shortCode}/preview": {
"$ref": "paths/{shortCode}_preview.json"
},
"/rest/v1/authenticate": {
"$ref": "paths/v1_authenticate.json"
}
}

View File

@@ -26,7 +26,7 @@ $config['entity_manager']['connection'] = [
$sm->setService('config', $config);
// Create database
$process = new Process('vendor/bin/doctrine orm:schema-tool:create --no-interaction -q --test', __DIR__);
$process = new Process(['vendor/bin/doctrine', 'orm:schema-tool:create', '--no-interaction', '-q', '--test'], __DIR__);
$process->inheritEnvironmentVariables()
->mustRun();

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env bash
# Run docker containers if they are not up yet
if [[ $(docker ps | grep shlink_swoole) ]]; then :
else
if ! [[ $(docker ps | grep shlink_swoole) ]]; then
docker-compose up -d
fi

View File

@@ -3,13 +3,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
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 Symfony\Component\Console\Style\SymfonyStyle;
use function array_filter;
use function array_map;
use function sprintf;
@@ -46,7 +46,6 @@ class ListKeysCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$enabledOnly = $input->getOption('enabledOnly');
$rows = array_map(function (ApiKey $apiKey) use ($enabledOnly) {
@@ -62,7 +61,7 @@ class ListKeysCommand extends Command
return $rowData;
}, $this->apiKeyService->listKeys($enabledOnly));
$io->table(array_filter([
ShlinkTable::fromOutput($output)->render(array_filter([
'Key',
! $enabledOnly ? 'Is enabled' : null,
'Expiration date',

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\VisitsParams;
@@ -69,7 +70,6 @@ class GetVisitsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$shortCode = $input->getArgument('shortCode');
$startDate = $this->getDateOption($input, 'startDate');
$endDate = $this->getDateOption($input, 'endDate');
@@ -82,7 +82,7 @@ class GetVisitsCommand extends Command
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
}, $visits);
$io->table(['Referer', 'Date', 'User agent', 'Country'], $rows);
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
}
private function getDateOption(InputInterface $input, $key)

View File

@@ -3,8 +3,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Adapter\PaginableRepositoryAdapter;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Console\Command\Command;
@@ -12,6 +14,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Zend\Paginator\Paginator;
use function array_values;
use function count;
use function explode;
@@ -78,39 +81,56 @@ class ListShortUrlsCommand extends Command
$searchTerm = $input->getOption('searchTerm');
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? explode(',', $tags) : [];
$showTags = $input->getOption('showTags');
$showTags = (bool) $input->getOption('showTags');
$transformer = new ShortUrlDataTransformer($this->domainConfig);
do {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$result = $this->renderPage($input, $output, $page, $searchTerm, $tags, $showTags, $transformer);
$page++;
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = 'Tags';
}
$rows = [];
foreach ($result as $row) {
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = array_values($shortUrl);
}
$io->table($headers, $rows);
if ($this->isLastPage($result)) {
$continue = false;
$io->success('Short URLs properly listed');
} else {
$continue = $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
}
$continue = $this->isLastPage($result)
? false
: $io->confirm(sprintf('Continue with page <options=bold>%s</>?', $page), false);
} while ($continue);
$io->newLine();
$io->success('Short URLs properly listed');
}
private function renderPage(
InputInterface $input,
OutputInterface $output,
int $page,
?string $searchTerm,
array $tags,
bool $showTags,
DataTransformerInterface $transformer
): Paginator {
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
if ($showTags) {
$headers[] = 'Tags';
}
$rows = [];
foreach ($result as $row) {
$shortUrl = $transformer->transform($row);
if ($showTags) {
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
} else {
unset($shortUrl['tags']);
}
unset($shortUrl['originalUrl']);
$rows[] = array_values($shortUrl);
}
ShlinkTable::fromOutput($output)->render($headers, $rows, $this->formatCurrentPageMessage(
$result,
'Page %s of %s'
));
return $result;
}
private function processOrderBy(InputInterface $input)

View File

@@ -3,12 +3,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function Functional\map;
class ListTagsCommand extends Command
@@ -33,8 +33,7 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
$io->table(['Name'], $this->getTagsRows());
ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows());
}
private function getTagsRows(): array

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Console;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\OutputInterface;
final class ShlinkTable
{
private const DEFAULT_STYLE_NAME = 'default';
private const TABLE_TITLE_STYLE = '<options=bold> %s </>';
/** @var Table|null */
private $baseTable;
public function __construct(Table $baseTable)
{
$this->baseTable = $baseTable;
}
public static function fromOutput(OutputInterface $output): self
{
return new self(new Table($output));
}
public function render(array $headers, array $rows, ?string $footerTitle = null, ?string $headerTitle = null): void
{
$style = Table::getStyleDefinition(self::DEFAULT_STYLE_NAME);
$style->setFooterTitleFormat(self::TABLE_TITLE_STYLE)
->setHeaderTitleFormat(self::TABLE_TITLE_STYLE);
$table = clone $this->baseTable;
$table->setStyle($style)
->setHeaders($headers)
->setRows($rows)
->setFooterTitle($footerTitle)
->setHeaderTitle($headerTitle)
->render();
}
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Exec;
use const PHP_SAPI;
use function Shlinkio\Shlink\Common\env;
abstract class ExecutionContext
{
public const WEB = 'shlink_web';
public const CLI = 'shlink_cli';
public static function currentContextIsSwoole(): bool
{
return PHP_SAPI === 'cli' && env('CURRENT_SHLINK_CONTEXT', self::WEB) === self::WEB;
}
}

View File

@@ -13,6 +13,7 @@ use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
use function Functional\contains;
use function Shlinkio\Shlink\Common\env;
use function sys_get_temp_dir;
class CacheFactory implements FactoryInterface
{
@@ -23,6 +24,7 @@ class CacheFactory implements FactoryInterface
Cache\PhpFileCache::class,
Cache\MemcachedCache::class,
];
private const DEFAULT_MEMCACHED_PORT = 11211;
/**
* Create an object
@@ -40,7 +42,7 @@ class CacheFactory implements FactoryInterface
{
$appOptions = $container->get(AppOptions::class);
$adapter = $this->getAdapter($container);
$adapter->setNamespace($appOptions->__toString());
$adapter->setNamespace((string) $appOptions);
return $adapter;
}
@@ -65,25 +67,35 @@ class CacheFactory implements FactoryInterface
return new $cacheConfig['adapter']();
case Cache\FilesystemCache::class:
case Cache\PhpFileCache::class:
return new $cacheConfig['adapter']($cacheConfig['options']['dir']);
return new $cacheConfig['adapter']($cacheConfig['options']['dir'] ?? sys_get_temp_dir());
case Cache\MemcachedCache::class:
$memcached = new Memcached();
$servers = $cacheConfig['options']['servers'] ?? [];
foreach ($servers as $server) {
if (! isset($server['host'])) {
continue;
}
$port = isset($server['port']) ? (int) $server['port'] : 11211;
$memcached->addServer($server['host'], $port);
}
$cache = new Cache\MemcachedCache();
$cache->setMemcached($memcached);
$cache->setMemcached($this->buildMemcached($cacheConfig));
return $cache;
default:
return new Cache\ArrayCache();
}
}
private function buildMemcached(array $cacheConfig): Memcached
{
$memcached = new Memcached();
$servers = $cacheConfig['options']['servers'] ?? [];
foreach ($servers as $server) {
$this->addMemcachedServer($memcached, $server);
}
return $memcached;
}
private function addMemcachedServer(Memcached $memcached, array $server): void
{
if (! isset($server['host'])) {
return;
}
$port = (int) ($server['port'] ?? self::DEFAULT_MEMCACHED_PORT);
$memcached->addServer($server['host'], $port);
}
}

View File

@@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Common\Factory;
use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\Cache;
use Doctrine\Common\Persistence\Mapping\Driver\PHPDriver;
use Doctrine\DBAL\DBALException;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\EntityManager;
@@ -37,12 +38,9 @@ class EntityManagerFactory implements FactoryInterface
Type::addType(ChronosDateTimeType::CHRONOS_DATETIME, ChronosDateTimeType::class);
}
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
$ormConfig['entities_paths'] ?? [],
$isDevMode,
$ormConfig['proxies_dir'] ?? null,
$cache,
false
));
$config = Setup::createConfiguration($isDevMode, $ormConfig['proxies_dir'] ?? null, $cache);
$config->setMetadataDriverImpl(new PHPDriver($ormConfig['entities_mappings'] ?? []));
return EntityManager::create($connectionConfig, $config);
}
}

View File

@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Logger\Swoole;
use Interop\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Zend\Expressive\Swoole\Log;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class AccessLogFactory 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.
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$config = $container->has('config') ? $container->get('config') : [];
$config = $config['zend-expressive-swoole']['swoole-http-server']['logger'] ?? [];
return new Log\Psr3AccessLogDecorator(
$this->getLogger($container, $config),
$this->getFormatter($container, $config),
$config['use-hostname-lookups'] ?? false
);
}
private function getLogger(ContainerInterface $container, array $config): LoggerInterface
{
$loggerName = $config['logger_name'] ?? LoggerInterface::class;
return $container->has($loggerName) ? $container->get($loggerName) : new Log\StdoutLogger();
}
private function getFormatter(ContainerInterface $container, array $config): Log\AccessLogFormatterInterface
{
if ($container->has(Log\AccessLogFormatterInterface::class)) {
return $container->get(Log\AccessLogFormatterInterface::class);
}
return new Log\AccessLogFormatter($config['format'] ?? Log\AccessLogFormatter::FORMAT_COMMON);
}
}

View File

@@ -7,6 +7,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Zend\Paginator\Paginator;
use Zend\Stdlib\ArrayUtils;
use function array_map;
use function sprintf;
trait PaginatorUtilsTrait
{
@@ -39,4 +40,9 @@ trait PaginatorUtilsTrait
{
return $paginator->getCurrentPageNumber() >= $paginator->count();
}
private function formatCurrentPageMessage(Paginator $paginator, string $pattern): string
{
return sprintf($pattern, $paginator->getCurrentPageNumber(), $paginator->count());
}
}

View File

@@ -20,5 +20,6 @@ class TranslatorExtension implements ExtensionInterface
public function register(Engine $engine): void
{
$engine->registerFunction('translate', [$this->translator, 'translate']);
$engine->registerFunction('locale', [$this->translator, 'getLocale']);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Console;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use ReflectionObject;
use Shlinkio\Shlink\Common\Console\ShlinkTable;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableStyle;
use Symfony\Component\Console\Output\OutputInterface;
class ShlinkTableTest extends TestCase
{
/** @var ShlinkTable */
private $shlinkTable;
/** @var ObjectProphecy */
private $baseTable;
public function setUp()
{
$this->baseTable = $this->prophesize(Table::class);
$this->shlinkTable = new ShlinkTable($this->baseTable->reveal());
}
/**
* @test
*/
public function renderMakesTableToBeRenderedWithProvidedInfo()
{
$headers = [];
$rows = [[]];
$headerTitle = 'Header';
$footerTitle = 'Footer';
$setStyle = $this->baseTable->setStyle(Argument::type(TableStyle::class))->willReturn(
$this->baseTable->reveal()
);
$setHeaders = $this->baseTable->setHeaders($headers)->willReturn($this->baseTable->reveal());
$setRows = $this->baseTable->setRows($rows)->willReturn($this->baseTable->reveal());
$setFooterTitle = $this->baseTable->setFooterTitle($footerTitle)->willReturn($this->baseTable->reveal());
$setHeaderTitle = $this->baseTable->setHeaderTitle($headerTitle)->willReturn($this->baseTable->reveal());
$render = $this->baseTable->render()->willReturn($this->baseTable->reveal());
$this->shlinkTable->render($headers, $rows, $footerTitle, $headerTitle);
$setStyle->shouldHaveBeenCalledOnce();
$setHeaders->shouldHaveBeenCalledOnce();
$setRows->shouldHaveBeenCalledOnce();
$setFooterTitle->shouldHaveBeenCalledOnce();
$setHeaderTitle->shouldHaveBeenCalledOnce();
$render->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function newTableIsCreatedForFactoryMethod()
{
$instance = ShlinkTable::fromOutput($this->prophesize(OutputInterface::class)->reveal());
$ref = new ReflectionObject($instance);
$baseTable = $ref->getProperty('baseTable');
$baseTable->setAccessible(true);
$this->assertInstanceOf(Table::class, $baseTable->getValue($instance));
}
}

View File

@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Logger\Swoole;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use ReflectionObject;
use Shlinkio\Shlink\Common\Logger\Swoole\AccessLogFactory;
use Zend\Expressive\Swoole\Log\AccessLogFormatter;
use Zend\Expressive\Swoole\Log\AccessLogFormatterInterface;
use Zend\Expressive\Swoole\Log\Psr3AccessLogDecorator;
use Zend\Expressive\Swoole\Log\StdoutLogger;
use Zend\ServiceManager\ServiceManager;
use function is_string;
class AccessLogFactoryTest extends TestCase
{
/** @var AccessLogFactory */
private $factory;
public function setUp()
{
$this->factory = new AccessLogFactory();
}
/**
* @test
*/
public function createsService()
{
$service = ($this->factory)(new ServiceManager(), '');
$this->assertInstanceOf(Psr3AccessLogDecorator::class, $service);
}
/**
* @test
* @dataProvider provideLoggers
* @param array $config
* @param string|LoggerInterface $expectedLogger
*/
public function wrapsProperLogger(array $config, $expectedLogger)
{
$service = ($this->factory)(new ServiceManager(['services' => $config]), '');
$ref = new ReflectionObject($service);
$loggerProp = $ref->getProperty('logger');
$loggerProp->setAccessible(true);
$logger = $loggerProp->getValue($service);
if (is_string($expectedLogger)) {
$this->assertInstanceOf($expectedLogger, $logger);
} else {
$this->assertSame($expectedLogger, $logger);
}
}
public function provideLoggers(): iterable
{
yield 'without-any-logger' => [[], StdoutLogger::class];
yield 'with-standard-logger' => (function () {
$logger = new NullLogger();
return [[LoggerInterface::class => $logger], $logger];
})();
yield 'with-custom-logger' => (function () {
$logger = new NullLogger();
return [[
'config' => [
'zend-expressive-swoole' => [
'swoole-http-server' => [
'logger' => [
'logger_name' => 'my-logger',
],
],
],
],
'my-logger' => $logger,
], $logger];
})();
}
/**
* @test
* @dataProvider provideFormatters
* @param array $config
* @param string|AccessLogFormatterInterface $expectedFormatter
*/
public function wrappsProperFormatter(array $config, $expectedFormatter, string $expectedFormat)
{
$service = ($this->factory)(new ServiceManager(['services' => $config]), '');
$ref = new ReflectionObject($service);
$formatterProp = $ref->getProperty('formatter');
$formatterProp->setAccessible(true);
$formatter = $formatterProp->getValue($service);
$ref = new ReflectionObject($formatter);
$formatProp = $ref->getProperty('format');
$formatProp->setAccessible(true);
$format = $formatProp->getValue($formatter);
if (is_string($expectedFormatter)) {
$this->assertInstanceOf($expectedFormatter, $formatter);
} else {
$this->assertSame($expectedFormatter, $formatter);
}
$this->assertSame($expectedFormat, $format);
}
public function provideFormatters(): iterable
{
yield 'with-registered-formatter-and-default-format' => (function () {
$formatter = new AccessLogFormatter();
return [[AccessLogFormatterInterface::class => $formatter], $formatter, AccessLogFormatter::FORMAT_COMMON];
})();
yield 'with-registered-formatter-and-custom-format' => (function () {
$formatter = new AccessLogFormatter(AccessLogFormatter::FORMAT_AGENT);
return [[AccessLogFormatterInterface::class => $formatter], $formatter, AccessLogFormatter::FORMAT_AGENT];
})();
yield 'with-no-formatter-and-not-configured-format' => [
[],
AccessLogFormatter::class,
AccessLogFormatter::FORMAT_COMMON,
];
yield 'with-no-formatter-and-configured-format' => [[
'config' => [
'zend-expressive-swoole' => [
'swoole-http-server' => [
'logger' => [
'format' => AccessLogFormatter::FORMAT_COMBINED_DEBIAN,
],
],
],
],
], AccessLogFormatter::class, AccessLogFormatter::FORMAT_COMBINED_DEBIAN];
}
}

View File

@@ -25,11 +25,14 @@ class TranslatorExtensionTest extends TestCase
public function properFunctionsAreReturned()
{
$engine = $this->prophesize(Engine::class);
$registerFunction = $engine->registerFunction('translate', Argument::type('callable'))->will(function () {
$registerTranslate = $engine->registerFunction('translate', Argument::type('callable'))->will(function () {
});
$registerLocale = $engine->registerFunction('locale', Argument::type('array'))->will(function () {
});
$this->extension->register($engine->reveal());
$registerFunction->shouldHaveBeenCalledOnce();
$registerTranslate->shouldHaveBeenCalledOnce();
$registerLocale->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
/** @var $metadata ClassMetadata */
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('short_urls')
->setCustomRepositoryClass(Repository\ShortUrlRepository::class);
$builder->createField('id', Type::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('longUrl', Type::STRING)
->columnName('original_url')
->length(1024)
->build();
$builder->createField('shortCode', Type::STRING)
->columnName('short_code')
->unique()
->length(255)
->build();
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('date_created')
->build();
$builder->createField('validSince', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('valid_since')
->nullable()
->build();
$builder->createField('validUntil', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('valid_until')
->nullable()
->build();
$builder->createField('maxVisits', Type::INTEGER)
->columnName('max_visits')
->nullable()
->build();
$builder->createOneToMany('visits', Entity\Visit::class)
->mappedBy('shortUrl')
->fetchExtraLazy()
->build();
$builder->createManyToMany('tags', Entity\Tag::class)
->setJoinTable('short_urls_in_tags')
->addInverseJoinColumn('tag_id', 'id')
->addJoinColumn('short_url_id', 'id')
->build();

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
/** @var $metadata ClassMetadata */
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('tags')
->setCustomRepositoryClass(Repository\TagRepository::class);
$builder->createField('id', Type::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('name', Type::STRING)
->unique()
->build();

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
/** @var $metadata ClassMetadata */
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('visits')
->setCustomRepositoryClass(Repository\VisitRepository::class);
$builder->createField('id', Type::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('referer', Type::STRING)
->nullable()
->length(256)
->build();
$builder->createField('date', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('`date`')
->build();
$builder->createField('remoteAddr', Type::STRING)
->columnName('remote_addr')
->length(256)
->nullable()
->build();
$builder->createField('userAgent', Type::STRING)
->columnName('user_agent')
->length(512)
->nullable()
->build();
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
->addJoinColumn('short_url_id', 'id', false)
->build();
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
->addJoinColumn('visit_location_id', 'id')
->cascadePersist()
->build();

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
/** @var $metadata ClassMetadata */
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('visit_locations');
$builder->createField('id', Type::BIGINT)
->columnName('id')
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$columns = [
'country_code' => 'countryCode',
'country_name' => 'countryName',
'region_name' => 'regionName',
'city_name' => 'cityName',
'latitude' => 'latitude',
'longitude' => 'longitude',
'timezone' => 'timezone',
];
foreach ($columns as $columnName => $fieldName) {
$builder->createField($fieldName, Type::STRING)
->columnName($columnName)
->nullable()
->build();
}

View File

@@ -5,8 +5,8 @@ return [
'entity_manager' => [
'orm' => [
'entities_paths' => [
__DIR__ . '/../src/Entity',
'entities_mappings' => [
__DIR__ . '/../config/entities-mappings',
],
],
],

View File

@@ -6,72 +6,27 @@ namespace Shlinkio\Shlink\Core\Entity;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use function count;
/**
* Class ShortUrl
* @author
* @link
*
* @ORM\Entity(repositoryClass=ShortUrlRepository::class)
* @ORM\Table(name="short_urls")
*/
class ShortUrl extends AbstractEntity
{
/**
* @var string
* @ORM\Column(name="original_url", type="string", nullable=false, length=1024)
*/
/** @var string */
private $longUrl;
/**
* @var string
* @ORM\Column(
* name="short_code",
* type="string",
* nullable=false,
* length=255,
* unique=true
* )
*/
/** @var string */
private $shortCode;
/**
* @var Chronos
* @ORM\Column(name="date_created", type="chronos_datetime")
*/
/** @var Chronos */
private $dateCreated;
/**
* @var Collection|Visit[]
* @ORM\OneToMany(targetEntity=Visit::class, mappedBy="shortUrl", fetch="EXTRA_LAZY")
*/
/** @var Collection|Visit[] */
private $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")
* })
*/
/** @var Collection|Tag[] */
private $tags;
/**
* @var Chronos|null
* @ORM\Column(name="valid_since", type="chronos_datetime", nullable=true)
*/
/** @var Chronos|null */
private $validSince;
/**
* @var Chronos|null
* @ORM\Column(name="valid_until", type="chronos_datetime", nullable=true)
*/
/** @var Chronos|null */
private $validUntil;
/**
* @var integer
* @ORM\Column(name="max_visits", type="integer", nullable=true)
*/
/** @var integer|null */
private $maxVisits;
public function __construct(string $longUrl, ShortUrlMeta $meta = null)

View File

@@ -3,25 +3,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Repository\TagRepository;
/**
* Class Tag
* @author
* @link
*
* @ORM\Entity(repositoryClass=TagRepository::class)
* @ORM\Table(name="tags")
*/
class Tag extends AbstractEntity implements JsonSerializable
{
/**
* @var string
* @ORM\Column(unique=true)
*/
/** @var string */
private $name;
public function __construct(string $name)

View File

@@ -4,55 +4,27 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use Cake\Chronos\Chronos;
use Doctrine\ORM\Mapping as ORM;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
/**
* Class Visit
* @author
* @link
*
* @ORM\Entity(repositoryClass=VisitRepository::class)
* @ORM\Table(name="visits")
*/
class Visit extends AbstractEntity implements JsonSerializable
{
/**
* @var string
* @ORM\Column(type="string", length=256, nullable=true)
*/
/** @var string */
private $referer;
/**
* @var Chronos
* @ORM\Column(type="chronos_datetime", nullable=false)
*/
/** @var Chronos */
private $date;
/**
* @var string|null
* @ORM\Column(type="string", length=256, name="remote_addr", nullable=true)
*/
/** @var string|null */
private $remoteAddr;
/**
* @var string
* @ORM\Column(type="string", length=512, name="user_agent", nullable=true)
*/
/** @var string */
private $userAgent;
/**
* @var ShortUrl
* @ORM\ManyToOne(targetEntity=ShortUrl::class)
* @ORM\JoinColumn(name="short_url_id", referencedColumnName="id")
*/
/** @var ShortUrl */
private $shortUrl;
/**
* @var VisitLocation
* @ORM\ManyToOne(targetEntity=VisitLocation::class, cascade={"persist"})
* @ORM\JoinColumn(name="visit_location_id", referencedColumnName="id", nullable=true)
*/
/** @var VisitLocation */
private $visitLocation;
public function __construct(ShortUrl $shortUrl, Visitor $visitor, ?Chronos $date = null)
@@ -88,9 +60,9 @@ class Visit extends AbstractEntity implements JsonSerializable
return ! empty($this->remoteAddr);
}
public function getVisitLocation(): VisitLocation
public function getVisitLocation(): VisitLocationInterface
{
return $this->visitLocation;
return $this->visitLocation ?? new UnknownVisitLocation();
}
public function locate(VisitLocation $visitLocation): self

View File

@@ -3,55 +3,25 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\ORM\Mapping as ORM;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
use function array_key_exists;
/**
* Class VisitLocation
* @author
* @link
*
* @ORM\Entity()
* @ORM\Table(name="visit_locations")
*/
class VisitLocation extends AbstractEntity implements JsonSerializable
class VisitLocation extends AbstractEntity implements VisitLocationInterface
{
/**
* @var string
* @ORM\Column(nullable=true, name="country_code")
*/
/** @var string */
private $countryCode;
/**
* @var string
* @ORM\Column(nullable=true, name="country_name")
*/
/** @var string */
private $countryName;
/**
* @var string
* @ORM\Column(nullable=true, name="region_name")
*/
/** @var string */
private $regionName;
/**
* @var string
* @ORM\Column(nullable=true, name="city_name")
*/
/** @var string */
private $cityName;
/**
* @var string
* @ORM\Column(nullable=true, name="latitude")
*/
/** @var string */
private $latitude;
/**
* @var string
* @ORM\Column(nullable=true, name="longitude")
*/
/** @var string */
private $longitude;
/**
* @var string
* @ORM\Column(nullable=true, name="timezone")
*/
/** @var string */
private $timezone;
public function __construct(array $locationInfo)

View File

@@ -15,69 +15,48 @@ class AppOptions extends AbstractOptions
private $name = '';
/** @var string */
private $version = '1.0';
/** @var string */
/**
* @var string
* @deprecated
*/
private $secretKey = '';
/** @var string|null */
private $disableTrackParam;
/**
* AppOptions constructor.
* @param array|null|\Traversable $options
*/
public function __construct($options = null)
{
parent::__construct($options);
}
/**
* @return string
*/
public function getName()
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
* @return $this
*/
protected function setName($name)
protected function setName(string $name): self
{
$this->name = $name;
return $this;
}
/**
* @return string
*/
public function getVersion()
public function getVersion(): string
{
return $this->version;
}
/**
* @param string $version
* @return $this
*/
protected function setVersion($version)
protected function setVersion(string $version): self
{
$this->version = $version;
return $this;
}
/**
* @return mixed
* @deprecated
*/
public function getSecretKey()
public function getSecretKey(): string
{
return $this->secretKey;
}
/**
* @param mixed $secretKey
* @return $this
* @deprecated
*/
protected function setSecretKey($secretKey)
protected function setSecretKey(string $secretKey): self
{
$this->secretKey = $secretKey;
return $this;
@@ -86,25 +65,18 @@ class AppOptions extends AbstractOptions
/**
* @return string|null
*/
public function getDisableTrackParam()
public function getDisableTrackParam(): ?string
{
return $this->disableTrackParam;
}
/**
* @param string|null $disableTrackParam
* @return $this|self
*/
protected function setDisableTrackParam($disableTrackParam): self
protected function setDisableTrackParam(?string $disableTrackParam): self
{
$this->disableTrackParam = $disableTrackParam;
return $this;
}
/**
* @return string
*/
public function __toString()
public function __toString(): string
{
return sprintf('%s:v%s', $this->name, $this->version);
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Model;
final class UnknownVisitLocation implements VisitLocationInterface
{
public function getCountryName(): string
{
return 'Unknown';
}
public function getLatitude(): string
{
return '0.0';
}
public function getLongitude(): string
{
return '0.0';
}
public function getCityName(): string
{
return 'Unknown';
}
/**
* Specify data which should be serialized to JSON
* @link https://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 [
'countryCode' => 'Unknown',
'countryName' => 'Unknown',
'regionName' => 'Unknown',
'cityName' => 'Unknown',
'latitude' => '0.0',
'longitude' => '0.0',
'timezone' => 'Unknown',
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Model;
use JsonSerializable;
interface VisitLocationInterface extends JsonSerializable
{
public function getCountryName(): string;
public function getLatitude(): string;
public function getLongitude(): string;
public function getCityName(): string;
}

View File

@@ -1,31 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title><?= $this->section('title', '') ?> | URL shortener</title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" />
<style>
body {padding-top: 60px;}
.app {display: flex; min-height: 100vh; flex-direction: column;}
.app-content {flex: 1;}
.app-footer p {margin-bottom: 20px;}
</style>
<?= $this->section('stylesheets', '') ?>
</head>
<body class="app">
<div class="app-content">
<main class="container">
<?= $this->section('main', '') ?>
</main>
</div>
<footer class="app-footer">
<div class="container">
<hr />
<p>&copy; <?= date('Y') ?> <a href="https://shlink.io">Shlink</a></p>
<html lang="<?= $this->locale() ?>">
<head>
<title><?= $this->section('title', '') ?> | URL shortener</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<link rel="shortcut icon" href="/favicon.ico">
<style>
body {padding-top: 60px;}
.app {display: flex; min-height: 100vh; flex-direction: column;}
.app-content {flex: 1;}
.app-footer p {margin-bottom: 20px;}
</style>
<?= $this->section('stylesheets', '') ?>
</head>
<body class="app">
<div class="app-content">
<main class="container">
<?= $this->section('main', '') ?>
</main>
</div>
</footer>
</body>
<footer class="app-footer">
<div class="container">
<hr>
<p>&copy; <?= date('Y') ?> <a href="https://shlink.io">Shlink</a></p>
</div>
</footer>
</body>
</html>

View File

@@ -20,7 +20,8 @@ use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Zend\Config\Writer\WriterInterface;
use function sprintf;
use function array_unshift;
use function implode;
class InstallCommand extends Command
{
@@ -133,7 +134,7 @@ class InstallCommand extends Command
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->execPhp(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
['vendor/doctrine/orm/bin/doctrine.php', 'orm:schema-tool:create'],
'Error generating database.',
$output
)) {
@@ -144,7 +145,7 @@ class InstallCommand extends Command
// Run database migrations
$this->io->write('Updating database...');
if (! $this->execPhp(
'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
['vendor/doctrine/migrations/bin/doctrine-migrations.php', 'migrations:migrate'],
'Error updating database.',
$output
)) {
@@ -154,16 +155,16 @@ class InstallCommand extends Command
// Generate proxies
$this->io->write('Generating proxies...');
if (! $this->execPhp(
'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
['vendor/doctrine/orm/bin/doctrine.php', 'orm:generate-proxies'],
'Error generating proxies.',
$output
)) {
return;
}
// Download GeoLite2 db filte
// Download GeoLite2 db file
$this->io->write('Downloading GeoLite2 db...');
if (! $this->execPhp('bin/cli visit:update-db', 'Error downloading GeoLite2 db.', $output)) {
if (! $this->execPhp(['bin/cli', 'visit:update-db'], 'Error downloading GeoLite2 db.', $output)) {
return;
}
@@ -215,7 +216,7 @@ class InstallCommand extends Command
return $config;
}
private function execPhp(string $command, string $errorMessage, OutputInterface $output): bool
private function execPhp(array $command, string $errorMessage, OutputInterface $output): bool
{
if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process');
@@ -225,12 +226,13 @@ class InstallCommand extends Command
$this->phpBinary = $this->phpFinder->find(false) ?: 'php';
}
array_unshift($command, $this->phpBinary);
$this->io->write(
' <options=bold>[Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"]</> ',
' <options=bold>[Running "' . implode(' ', $command) . '"]</> ',
false,
OutputInterface::VERBOSITY_VERBOSE
);
$process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command));
$process = $this->processHelper->run($output, $command);
if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>');
return true;

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
use Doctrine\DBAL\Types\Type;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
use Doctrine\ORM\Mapping\ClassMetadata;
use Shlinkio\Shlink\Common\Type\ChronosDateTimeType;
/** @var $metadata ClassMetadata */
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('api_keys');
$builder->createField('id', Type::BIGINT)
->makePrimaryKey()
->generatedValue('IDENTITY')
->option('unsigned', true)
->build();
$builder->createField('key', Type::STRING)
->columnName('`key`')
->unique()
->build();
$builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME)
->columnName('expiration_date')
->nullable()
->build();
$builder->createField('enabled', Type::BOOLEAN)
->build();

View File

@@ -5,8 +5,8 @@ return [
'entity_manager' => [
'orm' => [
'entities_paths' => [
__DIR__ . '/../src/Entity',
'entities_mappings' => [
__DIR__ . '/../config/entities-mappings',
],
],
],

View File

@@ -4,36 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Entity;
use Cake\Chronos\Chronos;
use Doctrine\ORM\Mapping as ORM;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
/**
* Class ApiKey
* @author Shlink
* @link http://shlink.io
*
* @ORM\Entity()
* @ORM\Table(name="api_keys")
*/
class ApiKey extends AbstractEntity
{
use StringUtilsTrait;
/**
* @var string
* @ORM\Column(name="`key`", nullable=false, unique=true)
*/
/** @var string */
private $key;
/**
* @var Chronos|null
* @ORM\Column(name="expiration_date", nullable=true, type="chronos_datetime")
*/
/** @var Chronos|null */
private $expirationDate;
/**
* @var bool
* @ORM\Column(type="boolean")
*/
/** @var bool */
private $enabled;
public function __construct(?Chronos $expirationDate = null)

View File

@@ -18,5 +18,4 @@
<!-- Paths to exclude -->
<exclude-pattern>config/params/*</exclude-pattern>
<exclude-pattern>public/index.php</exclude-pattern>
</ruleset>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -2,11 +2,8 @@
declare(strict_types=1);
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Common\Exec\ExecutionContext;
use Zend\Expressive\Application;
/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
putenv(sprintf('CURRENT_SHLINK_CONTEXT=%s', ExecutionContext::WEB));
$container->get(Application::class)->run();