Compare commits

..

54 Commits

Author SHA1 Message Date
Alejandro Celaya
3479bbbb36 Merge pull request #567 from acelaya-forks/hotfix/v1.20.2
Hotfix/v1.20.2
2019-12-06 23:09:20 +01:00
Alejandro Celaya
e1a1a0652f Merge pull request #566 from acelaya-forks/bugfix/date-parsing
Bugfix/date parsing
2019-12-06 22:52:22 +01:00
Alejandro Celaya
3e9b775114 Fixed failing test 2019-12-06 22:45:15 +01:00
Alejandro Celaya
57c91aca3c Updated changelog with v1.20.2 2019-12-06 22:40:08 +01:00
Alejandro Celaya
05a64b8d9e Ensured dates parsing does not mask actual validation errors 2019-12-06 22:38:22 +01:00
Alejandro Celaya
30780f9c5f Merge pull request #565 from acelaya-forks/bugfix/control-rename-tag
Bugfix/control rename tag
2019-12-06 21:13:54 +01:00
Alejandro Celaya
3455df9214 Updated changelog 2019-12-06 21:06:47 +01:00
Alejandro Celaya
27aa8f9875 Handled rename tag error from command 2019-12-06 21:04:52 +01:00
Alejandro Celaya
05451e3d1a Handled tag conflict from rename tag action 2019-12-06 21:03:27 +01:00
Alejandro Celaya
b9b3295b52 Ensured a specific exception is thrown from TagService when trying to rename a tag to the name of another tag which already exists 2019-12-06 20:44:41 +01:00
Alejandro Celaya
f62ed66e26 Created TagConflictException 2019-12-06 10:20:56 +01:00
Alejandro Celaya
e2a9a989ab Merge pull request #563 from acelaya-forks/bugfix/missing-yaml
Bugfix/missing yaml
2019-12-06 09:59:09 +01:00
Alejandro Celaya
4af27650cd Updated changelog 2019-12-06 09:52:23 +01:00
Alejandro Celaya
76a603104d Migrated migrations config file from yaml to plain PHP 2019-12-06 09:50:37 +01:00
Alejandro Celaya
115ca0da0f Added v1.20.1 to changelog 2019-11-17 11:29:54 +01:00
Alejandro Celaya
673b545a83 Merge pull request #551 from acelaya-forks/feature/non-shared-locker
Feature/non shared locker
2019-11-17 11:21:27 +01:00
Alejandro Celaya
d030fd1aa6 Updated GeolocationDbUpdater to always use a local lock even if redis config is provided 2019-11-17 11:09:37 +01:00
Alejandro Celaya
7c1e40be88 Updated docker docs regarding image versioning 2019-11-17 10:38:05 +01:00
Alejandro Celaya
b739619532 Merge pull request #550 from acelaya-forks/feature/fix-db-silent-errors
Feature/fix db silent errors
2019-11-17 10:08:57 +01:00
Alejandro Celaya
372b83d92f Updated changelog 2019-11-17 10:02:03 +01:00
Alejandro Celaya
4e3b5419d5 Created small helper composer command 2019-11-17 10:00:29 +01:00
Alejandro Celaya
c34d5a35e2 Updated database commands so that internal commands are run with mustRun 2019-11-17 09:52:45 +01:00
Alejandro Celaya
a959b5bf02 Merge pull request #549 from acelaya-forks/feature/use-own-test-domains
Replaced third party domains used in tests by custom shlink domains
2019-11-16 13:47:37 +01:00
Alejandro Celaya
45ac2c3c51 Replaced third party domains used in tests by custom shlink domains 2019-11-16 13:37:53 +01:00
Alejandro Celaya
f6bddc6f24 Merge pull request #548 from acelaya-forks/feature/redirect-to-idn
Handled IDN domains also on internal redirections when validating a URL
2019-11-16 12:46:02 +01:00
Alejandro Celaya
6b8fc3228e Handled IDN domains also on internal redirections when validating a URL 2019-11-16 12:38:45 +01:00
Alejandro Celaya
8cf1a95df5 Swoole is no longer experimental 2019-11-16 10:59:56 +01:00
Alejandro Celaya
b3ea2969c5 Merge pull request #547 from acelaya-forks/feature/support-idn
Feature/support idn
2019-11-16 10:32:49 +01:00
Alejandro Celaya
054bbb8d5a Updated changelog 2019-11-16 10:22:00 +01:00
Alejandro Celaya
19c1b29f59 Added tests for UrlValidator 2019-11-16 10:19:25 +01:00
Alejandro Celaya
264b8c2a9e Added support for IDN 2019-11-16 10:06:55 +01:00
Alejandro Celaya
ec33b95f97 Brought intl extension back to docker images and kept as a requirement 2019-11-16 09:46:42 +01:00
Alejandro Celaya
4437d5305f Merge pull request #546 from acelaya-forks/feature/image-version
Feature/image version
2019-11-15 22:24:42 +01:00
Alejandro Celaya
f20f01e22e Removed Intl from docker image 2019-11-15 22:23:07 +01:00
Alejandro Celaya
1ee30fe5dc Updated dev docker images 2019-11-15 22:05:34 +01:00
Alejandro Celaya
4dc026d7fc Merge pull request #544 from Starbix/master
Update dependencies and baseimage
2019-11-15 22:01:14 +01:00
Cédric Laubacher
1e862a8ee8 Readd specific alpine version 2019-11-15 21:42:07 +01:00
Alejandro Celaya
5ece2d1939 Merge pull request #539 from acelaya-forks/feature/forward-query
Feature/forward query
2019-11-15 20:43:11 +01:00
Alejandro Celaya
146e9100be Updated changelog 2019-11-15 20:30:36 +01:00
Cédric Laubacher
0c854edc6b Use specific PHP version 2019-11-15 19:16:29 +01:00
Cédric Laubacher
07d031e7b9 Update Dockerfile 2019-11-15 17:55:06 +01:00
Alejandro Celaya
705dc2ec39 Added forward of query string from short URLs to long one 2019-11-13 21:04:44 +01:00
Alejandro Celaya
3b9221c7d2 Ensured options for short.url:list command have required values 2019-11-13 20:24:59 +01:00
Alejandro Celaya
576d602ed0 Merge pull request #537 from acelaya-forks/feature/installer-3.1
Updated to installer 3.1
2019-11-10 13:15:30 +01:00
Alejandro Celaya
9df8bd63d4 Updated to installer 3.1 2019-11-10 13:07:57 +01:00
Alejandro Celaya
99c4802367 Fixed docker docs line break 2019-11-10 12:14:00 +01:00
Alejandro Celaya
94dc6f2053 Merge pull request #536 from acelaya-forks/feature/simplified-config-workers
Added workers nums handling to simplified config parser
2019-11-10 12:11:41 +01:00
Alejandro Celaya
d4005da35c Added workers nums handling to simplified config parser 2019-11-10 12:04:14 +01:00
Alejandro Celaya
cbe2c362d5 Merge pull request #535 from acelaya-forks/feature/api-test-script
Updated API tests script so that it throws the same exit code returne…
2019-11-09 12:15:09 +01:00
Alejandro Celaya
8bf79db66a Fixed typo 2019-11-09 12:08:22 +01:00
Alejandro Celaya
b87964f716 Updated API tests script so that it throws the same exit code returned by phpunit 2019-11-09 11:25:33 +01:00
Alejandro Celaya
b0a574f578 Merge pull request #533 from acelaya-forks/feature/custom-workers
Feature/custom workers
2019-11-09 11:17:03 +01:00
Alejandro Celaya
92dc3019de Updated changelog 2019-11-09 11:08:28 +01:00
Alejandro Celaya
d8f92cb2be Added web worker num and task worker num to docker image config 2019-11-09 11:05:54 +01:00
44 changed files with 589 additions and 190 deletions

View File

@@ -18,6 +18,10 @@ services:
- postgresql
- docker
cache:
directories:
- $HOME/.composer/cache/files
before_install:
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- yes | pecl install swoole
@@ -25,7 +29,7 @@ before_install:
install:
- composer self-update
- composer install --no-interaction
- composer install --no-interaction --prefer-dist
before_script:
- mysql -e 'CREATE DATABASE shlink_test;'

View File

@@ -4,6 +4,57 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 1.20.2 - 2019-12-06
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#561](https://github.com/shlinkio/shlink/issues/561) Fixed `db:migrate` command failing because yaml extension is not installed, which makes config file not to be readable.
* [#562](https://github.com/shlinkio/shlink/issues/562) Fixed internal server error being returned when renaming a tag to another tag's name. Now a meaningful API error with status 409 is returned.
* [#555](https://github.com/shlinkio/shlink/issues/555) Fixed internal server error being returned when invalid dates are provided for new short URLs. Now a 400 is returned, as intended.
## 1.20.1 - 2019-11-17
#### Added
* [#519](https://github.com/shlinkio/shlink/issues/519) Documented how to customize web workers and task workers for the docker image.
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#512](https://github.com/shlinkio/shlink/issues/512) Fixed query params not being properly forwarded from short URL to long one.
* [#540](https://github.com/shlinkio/shlink/issues/540) Fixed errors thrown when creating short URLs if the original URL has an internationalized domain name and URL validation is enabled.
* [#528](https://github.com/shlinkio/shlink/issues/528) Ensured `db:create` and `db:migrate` commands do not silently fail when run as part of `install` or `update`.
* [#518](https://github.com/shlinkio/shlink/issues/518) Fixed service which updates Geolite db file to use a local lock instead of a shared one, since every shlink instance holds its own db instance.
## 1.20.0 - 2019-11-02
#### Added
@@ -34,7 +85,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
#### Removed
* * *Nothing*
* *Nothing*
#### Fixed

View File

@@ -1,10 +1,10 @@
FROM php:7.3.8-cli-alpine3.10
FROM php:7.3.11-alpine3.10
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
ARG SHLINK_VERSION=1.18.1
ARG SHLINK_VERSION=1.20.0
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.4.8
ENV COMPOSER_VERSION 1.9.0
ENV SWOOLE_VERSION 4.4.12
ENV COMPOSER_VERSION 1.9.1
WORKDIR /etc/shlink
@@ -17,7 +17,7 @@ RUN \
# Install postgres
apk add --no-cache postgresql-dev && \
docker-php-ext-install -j"$(nproc)" pdo_pgsql && \
# [Deprecated] Install intl
# Install intl
apk add --no-cache icu-dev && \
docker-php-ext-install -j"$(nproc)" intl && \
# Install zip and gd

View File

@@ -103,8 +103,6 @@ Despite how you built the project, you are going to need to install it now, by f
* **Using swoole:**
**Important!** Swoole support is still experimental. Use it with care, and report any found issue.
First you need to install the swoole PHP extension with [pecl](https://pecl.php.net/package/swoole), `pecl install swoole`.
Once installed, it's actually pretty easy to get shlink up and running with swoole. Just run `./vendor/bin/zend-expressive-swoole start -d` and you will get shlink running on port 8080.

View File

@@ -10,4 +10,9 @@ vendor/bin/zend-expressive-swoole start -d
sleep 2
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always
testsExitCode=$?
vendor/bin/zend-expressive-swoole stop
# Exit this script with the same code as the tests. If tests failed, this script has to fail
exit $testsExitCode

View File

@@ -36,7 +36,7 @@
"pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.2.1",
"shlinkio/shlink-event-dispatcher": "^1.0",
"shlinkio/shlink-installer": "^3.0",
"shlinkio/shlink-installer": "^3.1",
"shlinkio/shlink-ip-geolocation": "^1.1",
"symfony/console": "^4.3",
"symfony/filesystem": "^4.3",
@@ -102,11 +102,9 @@
"@test:ci",
"@infect:ci"
],
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=5 -c phpstan.neon",
"test": [
"@test:unit",
"@test:db",
@@ -135,20 +133,19 @@
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
"test:api": "bin/test/run-api-tests.sh",
"test:pretty": [
"@test",
"phpdbg -qrr vendor/bin/phpcov merge build --html build/html"
],
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
"infect": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered",
"infect:ci": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --coverage=build",
"infect:show": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --show-mutations",
"infect:test": [
"@test:unit:ci",
"@infect:ci"
]
],
"clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php"
},
"scripts-descriptions": {
"ci": "<fg=blue;options=bold>Alias for \"cs\", \"stan\", \"test:ci\" and \"infect:ci\"</>",
@@ -171,7 +168,8 @@
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"infect:ci": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
"infect:show": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing and shows applied mutators</>",
"infect:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>"
"infect:test": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
"clean:dev": "<fg=blue;options=bold>Deletes artifacts which are gitignored and could affect dev env</>"
},
"config": {
"sort-packages": true

View File

@@ -10,7 +10,6 @@ return [
Plugin\UrlShortenerConfigCustomizer::class => [
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
Plugin\UrlShortenerConfigCustomizer::CHARS,
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
],
@@ -20,6 +19,8 @@ return [
Plugin\ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD,
Plugin\ApplicationConfigCustomizer::VISITS_THRESHOLD,
Plugin\ApplicationConfigCustomizer::BASE_PATH,
Plugin\ApplicationConfigCustomizer::WEB_WORKER_NUM,
Plugin\ApplicationConfigCustomizer::TASK_WORKER_NUM,
],
Plugin\DatabaseConfigCustomizer::class => [

View File

@@ -8,6 +8,10 @@ use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
use Symfony\Component\Lock;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
$localLockFactory = 'Shlinkio\Shlink\LocalLockFactory';
class_alias(Lock\Factory::class, $localLockFactory);
return [
'locks' => [
@@ -19,11 +23,14 @@ return [
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
Lock\Factory::class => ConfigAbstractFactory::class,
$localLockFactory => ConfigAbstractFactory::class,
],
'aliases' => [
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
'lock_store' => Lock\Store\FlockStore::class,
'lock_store' => 'local_lock_store',
'redis_lock_store' => Lock\Store\RedisStore::class,
'local_lock_store' => Lock\Store\FlockStore::class,
],
'delegators' => [
Lock\Store\RedisStore::class => [
@@ -39,6 +46,7 @@ return [
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
Lock\Factory::class => ['lock_store'],
$localLockFactory => ['local_lock_store'],
],
];

View File

@@ -1,9 +1,9 @@
FROM php:7.3.1-fpm-alpine3.8
FROM php:7.3.11-fpm-alpine3.10
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
ENV XDEBUG_VERSION "2.7.0RC1"
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV XDEBUG_VERSION 2.8.0
RUN apk update
@@ -13,17 +13,17 @@ RUN docker-php-ext-install iconv
RUN docker-php-ext-install mbstring
RUN docker-php-ext-install calendar
RUN apk add --no-cache --virtual sqlite-libs
RUN apk add --no-cache --virtual sqlite-dev
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache --virtual icu-dev
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache --virtual libzip-dev zlib-dev
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libpng-dev
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev

View File

@@ -1,10 +1,10 @@
FROM php:7.3.1-cli-alpine3.8
FROM php:7.3.11-alpine3.10
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.16
ENV APCU_BC_VERSION 1.0.4
ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5
ENV INOTIFY_VERSION 2.0.0
ENV SWOOLE_VERSION 4.4.8
ENV SWOOLE_VERSION 4.4.12
RUN apk update
@@ -14,17 +14,17 @@ RUN docker-php-ext-install iconv
RUN docker-php-ext-install mbstring
RUN docker-php-ext-install calendar
RUN apk add --no-cache --virtual sqlite-libs
RUN apk add --no-cache --virtual sqlite-dev
RUN apk add --no-cache sqlite-libs
RUN apk add --no-cache sqlite-dev
RUN docker-php-ext-install pdo_sqlite
RUN apk add --no-cache --virtual icu-dev
RUN apk add --no-cache icu-dev
RUN docker-php-ext-install intl
RUN apk add --no-cache --virtual libzip-dev zlib-dev
RUN apk add --no-cache libzip-dev zlib-dev
RUN docker-php-ext-install zip
RUN apk add --no-cache --virtual libpng-dev
RUN apk add --no-cache libpng-dev
RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev

View File

@@ -64,7 +64,7 @@ It is possible to use a set of env vars to make this shlink instance interact wi
* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
* `DB_HOST`: **[Mandatory]**. The host name of the server running the database engine.
* `DB_PORT`: [Optional]. The port in which the database service is running.
* Default value is based on the driver:
* Default value is based on the value provided for `DB_DRIVER`:
* **mysql** or **maria** -> `3306`
* **postgres** -> `5432`
@@ -97,7 +97,10 @@ This is the complete list of supported env vars:
* `DB_USER`: The username credential to be used when using an external database driver.
* `DB_PASSWORD`: The password credential to be used when using an external database driver.
* `DB_HOST`: The host name of the database server when using an external database driver.
* `DB_PORT`: The port in which the database service is running when using an external database driver. Defaults to **3306**.
* `DB_PORT`: The port in which the database service is running when using an external database driver.
* Default value is based on the value provided for `DB_DRIVER`:
* **mysql** or **maria** -> `3306`
* **postgres** -> `5432`
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x (after following redirects) is returned when trying to shorten a URL. Defaults to `true`.
@@ -105,6 +108,8 @@ This is the complete list of supported env vars:
* `REGULAR_404_REDIRECT_TO`: If a URL is provided here, when a user tries to access a URL not matching any one supported by the router, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
* `BASE_URL_REDIRECT_TO`: If a URL is provided here, when a user tries to access Shlink's base URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
* `BASE_PATH`: The base path from which you plan to serve shlink, in case you don't want to serve it from the root of the domain. Defaults to `''`.
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
@@ -138,6 +143,8 @@ docker run \
-e "BASE_URL_REDIRECT_TO=https://my-landing-page.com" \
-e "REDIS_SERVERS=tcp://172.20.0.1:6379,tcp://172.20.0.2:6379" \
-e "BASE_PATH=/my-campaign" \
-e WEB_WORKER_NUM=64 \
-e TASK_WORKER_NUM=32 \
shlinkio/shlink
```
@@ -159,6 +166,9 @@ The whole configuration should have this format, but it can be split into multip
"invalid_short_url_redirect_to": "https://my-landing-page.com",
"regular_404_redirect_to": "https://my-landing-page.com",
"base_url_redirect_to": "https://my-landing-page.com",
"base_path": "/my-campaign",
"web_worker_num": 64,
"task_worker_num": 32,
"redis_servers": [
"tcp://172.20.0.1:6379",
"tcp://172.20.0.2:6379"
@@ -176,7 +186,8 @@ The whole configuration should have this format, but it can be split into multip
```
> This is internally parsed to how shlink expects the config. If you are using a version previous to 1.17.0, this parser is not present and you need to provide a config structure like the one [documented previously](https://github.com/shlinkio/shlink-docker-image/tree/v1.16.3#provide-config-via-volumes).
> The `not_found_redirect_to` option has been deprecated when `regular_404_redirect_to` and `base_url_redirect_to` have been introduced. Use `invalid_short_url_redirect_to` instead (however, it will still work for backwards compatibility).
> The `not_found_redirect_to` option has been deprecated in v1.20. Use `invalid_short_url_redirect_to` instead (however, it will still work for backwards compatibility).
Once created just run shlink with the volume:
@@ -204,10 +215,10 @@ These are some considerations to take into account when running multiple instanc
## Versions
Versions of this image match the shlink version it contains.
Versioning on this docker image works as follows:
For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0.
* `X.X.X`: when providing a specific version number, the image version will match the shlink version it contains. For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0.
* `stable`: always holds the latest stable tag. For example, if latest shlink version is 1.20.0, installing `shlinkio/shlink:stable`, you will get an image containing shlink v1.20.0
* `latest`: always holds the latest contents in master, and it's considered unstable and not suitable for production.
The `latest` docker tag always holds the latest contents in master, and it's considered unestable and not suitable for production.
> There are no official shlink images previous to v1.15.0.
> **Important**: The docker image was introduced with shlink v1.15.0, so there are no official images previous to that versions.

View File

@@ -163,4 +163,13 @@ return [
'base_path' => env('BASE_PATH', ''),
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'options' => [
'worker_num' => (int) env('WEB_WORKER_NUM', 16),
'task_worker_num' => (int) env('TASK_WORKER_NUM', 16),
],
],
],
];

11
migrations.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
return [
'name' => 'ShlinkMigrations',
'migrations_namespace' => 'ShlinkMigrations',
'table_name' => 'migrations',
'migrations_directory' => 'data/migrations',
'custom_template' => 'data/migrations_template.txt',
];

View File

@@ -1,5 +0,0 @@
name: ShlinkMigrations
migrations_namespace: ShlinkMigrations
table_name: migrations
migrations_directory: data/migrations
custom_template: data/migrations_template.txt

View File

@@ -58,7 +58,7 @@ return [
],
ConfigAbstractFactory::class => [
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, Locker::class],
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'],
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class],

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Lock\Factory as Locker;
@@ -29,6 +30,11 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
protected function runPhpCommand(OutputInterface $output, array $command): void
{
array_unshift($command, $this->phpBinary);
$this->processHelper->run($output, $command, null, null, $output->getVerbosity());
$this->processHelper->mustRun($output, $command);
}
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName(), true);
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Doctrine\DBAL\Connection;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Input\InputInterface;
@@ -19,8 +18,8 @@ use function Functional\contains;
class CreateDatabaseCommand extends AbstractDatabaseCommand
{
public const NAME = 'db:create';
public const DOCTRINE_HELPER_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
public const DOCTRINE_HELPER_COMMAND = 'orm:schema-tool:create';
public const DOCTRINE_SCRIPT = 'vendor/doctrine/orm/bin/doctrine.php';
public const DOCTRINE_CREATE_SCHEMA_COMMAND = 'orm:schema-tool:create';
/** @var Connection */
private $regularConn;
@@ -61,7 +60,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
// Create database
$io->writeln('<fg=blue>Creating database tables...</>');
$this->runPhpCommand($output, [self::DOCTRINE_HELPER_SCRIPT, self::DOCTRINE_HELPER_COMMAND]);
$this->runPhpCommand($output, [self::DOCTRINE_SCRIPT, self::DOCTRINE_CREATE_SCHEMA_COMMAND]);
$io->success('Database properly created!');
return ExitCodes::EXIT_SUCCESS;
@@ -87,13 +86,8 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
private function schemaExists(): bool
{
// If at least one of the shlink tables exist, we will consider the database exists somehow.
// Any inconsistency will be taken care by the migrations
// Any inconsistency should be taken care by the migrations
$schemaManager = $this->regularConn->getSchemaManager();
return ! empty($schemaManager->listTableNames());
}
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName(), true);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Db;
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -13,8 +12,8 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class MigrateDatabaseCommand extends AbstractDatabaseCommand
{
public const NAME = 'db:migrate';
public const DOCTRINE_HELPER_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
public const DOCTRINE_HELPER_COMMAND = 'migrations:migrate';
public const DOCTRINE_MIGRATIONS_SCRIPT = 'vendor/doctrine/migrations/bin/doctrine-migrations.php';
public const DOCTRINE_MIGRATE_COMMAND = 'migrations:migrate';
protected function configure(): void
{
@@ -28,14 +27,9 @@ class MigrateDatabaseCommand extends AbstractDatabaseCommand
$io = new SymfonyStyle($input, $output);
$io->writeln('<fg=blue>Migrating database...</>');
$this->runPhpCommand($output, [self::DOCTRINE_HELPER_SCRIPT, self::DOCTRINE_HELPER_COMMAND]);
$this->runPhpCommand($output, [self::DOCTRINE_MIGRATIONS_SCRIPT, self::DOCTRINE_MIGRATE_COMMAND]);
$io->success('Database properly migrated!');
return ExitCodes::EXIT_SUCCESS;
}
protected function getLockConfig(): LockedCommandConfig
{
return new LockedCommandConfig($this->getName(), true);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
@@ -102,7 +101,7 @@ class GenerateShortUrlCommand extends Command
return;
}
$longUrl = $io->ask('A long URL was not provided. Which URL do you want to be shortened?');
$longUrl = $io->ask('Which URL do you want to shorten?');
if (! empty($longUrl)) {
$input->setArgument('longUrl', $longUrl);
}
@@ -127,8 +126,8 @@ class GenerateShortUrlCommand extends Command
new Uri($longUrl),
$tags,
ShortUrlMeta::createFromParams(
$this->getOptionalDate($input, 'validSince'),
$this->getOptionalDate($input, 'validUntil'),
$input->getOption('validSince'),
$input->getOption('validUntil'),
$customSlug,
$maxVisits !== null ? (int) $maxVisits : null,
$input->getOption('findIfExists'),
@@ -151,10 +150,4 @@ class GenerateShortUrlCommand extends Command
return ExitCodes::EXIT_FAILURE;
}
}
private function getOptionalDate(InputInterface $input, string $fieldName): ?Chronos
{
$since = $input->getOption($fieldName);
return $since !== null ? Chronos::parse($since) : null;
}
}

View File

@@ -62,26 +62,26 @@ class ListShortUrlsCommand extends Command
->addOption(
'page',
'p',
InputOption::VALUE_OPTIONAL,
InputOption::VALUE_REQUIRED,
sprintf('The first page to list (%s items per page)', ShortUrlRepositoryAdapter::ITEMS_PER_PAGE),
'1'
)
->addOption(
'searchTerm',
's',
InputOption::VALUE_OPTIONAL,
InputOption::VALUE_REQUIRED,
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
->addOption(
'tags',
't',
InputOption::VALUE_OPTIONAL,
InputOption::VALUE_REQUIRED,
'A comma-separated list of tags to filter results'
)
->addOption(
'orderBy',
'o',
InputOption::VALUE_OPTIONAL,
InputOption::VALUE_REQUIRED,
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not');

View File

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -50,6 +51,11 @@ class RenameTagCommand extends Command
} catch (EntityDoesNotExistException $e) {
$io->error(sprintf('A tag with name "%s" was not found', $oldName));
return ExitCodes::EXIT_FAILURE;
} catch (TagConflictException $e) {
$io->error(
sprintf('A tag with name "%s" cannot be renamed to "%s" because it already exists', $oldName, $newName)
);
return ExitCodes::EXIT_FAILURE;
}
}
}

View File

@@ -116,10 +116,10 @@ class CreateDatabaseCommandTest extends TestCase
$createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function () {
});
$listTables = $this->schemaManager->listTableNames()->willReturn([]);
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
$runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
CreateDatabaseCommand::DOCTRINE_HELPER_SCRIPT,
CreateDatabaseCommand::DOCTRINE_HELPER_COMMAND,
CreateDatabaseCommand::DOCTRINE_SCRIPT,
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
], Argument::cetera());
$this->commandTester->execute([]);

View File

@@ -48,36 +48,20 @@ class MigrateDatabaseCommandTest extends TestCase
$this->commandTester = new CommandTester($command);
}
/**
* @test
* @dataProvider provideVerbosities
*/
public function migrationsCommandIsRunWithProperVerbosity(int $verbosity): void
/** @test */
public function migrationsCommandIsRunWithProperVerbosity(): void
{
$runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
$runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
'/usr/local/bin/php',
MigrateDatabaseCommand::DOCTRINE_HELPER_SCRIPT,
MigrateDatabaseCommand::DOCTRINE_HELPER_COMMAND,
], null, null, $verbosity);
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
], Argument::cetera());
$this->commandTester->execute([], [
'verbosity' => $verbosity,
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
$this->assertStringContainsString('Migrating database...', $output);
$this->assertStringContainsString('Database properly migrated!', $output);
}
$this->assertStringContainsString('Migrating database...', $output);
$this->assertStringContainsString('Database properly migrated!', $output);
$runCommand->shouldHaveBeenCalledOnce();
}
public function provideVerbosities(): iterable
{
yield 'debug' => [OutputInterface::VERBOSITY_DEBUG];
yield 'normal' => [OutputInterface::VERBOSITY_NORMAL];
yield 'quiet' => [OutputInterface::VERBOSITY_QUIET];
yield 'verbose' => [OutputInterface::VERBOSITY_VERBOSE];
yield 'very verbose' => [OutputInterface::VERBOSITY_VERY_VERBOSE];
}
}

View File

@@ -31,6 +31,8 @@ return [
Service\Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Util\UrlValidator::class => ConfigAbstractFactory::class,
Action\RedirectAction::class => ConfigAbstractFactory::class,
Action\PixelAction::class => ConfigAbstractFactory::class,
Action\QrCodeAction::class => ConfigAbstractFactory::class,
@@ -52,13 +54,15 @@ return [
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => ['httpClient', 'em', Options\UrlShortenerOptions::class],
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Options\UrlShortenerOptions::class],
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
Service\ShortUrlService::class => ['em'],
Service\VisitService::class => ['em'],
Service\Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
Util\UrlValidator::class => ['httpClient'],
Action\RedirectAction::class => [
Service\UrlShortener::class,
Service\VisitsTracker::class,

View File

@@ -10,14 +10,19 @@ use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Zend\Diactoros\Uri;
use function array_key_exists;
use function array_merge;
use function GuzzleHttp\Psr7\parse_query;
use function http_build_query;
abstract class AbstractTrackingAction implements MiddlewareInterface
{
@@ -66,13 +71,25 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
$this->visitTracker->track($shortCode, Visitor::fromRequest($request));
}
return $this->createSuccessResp($url->getLongUrl());
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
return $this->createErrorResp($request, $handler);
}
}
private function buildUrlToRedirectTo(ShortUrl $shortUrl, array $currentQuery, ?string $disableTrackParam): string
{
$uri = new Uri($shortUrl->getLongUrl());
$hardcodedQuery = parse_query($uri->getQuery());
if ($disableTrackParam !== null) {
unset($currentQuery[$disableTrackParam]);
}
$mergedQuery = array_merge($hardcodedQuery, $currentQuery);
return (string) $uri->withQuery(http_build_query($mergedQuery));
}
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
abstract protected function createErrorResp(

View File

@@ -30,6 +30,8 @@ class SimplifiedConfigParser
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
'redis_servers' => ['redis', 'servers'],
'base_path' => ['router', 'base_path'],
'web_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'worker_num'],
'task_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use function sprintf;
class TagConflictException extends RuntimeException
{
public static function fromExistingTag(string $oldName, string $newName): self
{
return new self(sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName));
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Cake\Chronos\Chronos;
use DateTimeInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
@@ -96,7 +97,7 @@ final class ShortUrlMeta
}
/**
* @param string|Chronos|null $date
* @param string|DateTimeInterface|Chronos|null $date
*/
private function parseDateField($date): ?Chronos
{
@@ -104,6 +105,10 @@ final class ShortUrlMeta
return $date;
}
if ($date instanceof DateTimeInterface) {
return Chronos::instance($date);
}
return Chronos::parse($date);
}

View File

@@ -8,6 +8,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
@@ -64,17 +65,26 @@ class TagService implements TagServiceInterface
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
* @throws TagConflictException
* @throws ORM\OptimisticLockException
*/
public function renameTag($oldName, $newName): Tag
{
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
$criteria = ['name' => $oldName];
/** @var Tag|null $tag */
$tag = $this->em->getRepository(Tag::class)->findOneBy($criteria);
$tag = $repo->findOneBy($criteria);
if ($tag === null) {
throw EntityDoesNotExistException::createFromEntityAndConditions(Tag::class, $criteria);
}
$newNameExists = $newName !== $oldName && $repo->count(['name' => $newName]) > 0;
if ($newNameExists) {
throw TagConflictException::fromExistingTag($oldName, $newName);
}
$tag->rename($newName);
/** @var ORM\EntityManager $em */

View File

@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Service\Tag;
use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
interface TagServiceInterface
{
@@ -34,6 +35,7 @@ interface TagServiceInterface
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
* @throws TagConflictException
*/
public function renameTag($oldName, $newName): Tag;
}

View File

@@ -5,10 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM\EntityManagerInterface;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
@@ -20,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Throwable;
use function array_reduce;
@@ -28,16 +25,19 @@ class UrlShortener implements UrlShortenerInterface
{
use TagManagerTrait;
/** @var ClientInterface */
private $httpClient;
/** @var EntityManagerInterface */
private $em;
/** @var UrlShortenerOptions */
private $options;
/** @var UrlValidatorInterface */
private $urlValidator;
public function __construct(ClientInterface $httpClient, EntityManagerInterface $em, UrlShortenerOptions $options)
{
$this->httpClient = $httpClient;
public function __construct(
UrlValidatorInterface $urlValidator,
EntityManagerInterface $em,
UrlShortenerOptions $options
) {
$this->urlValidator = $urlValidator;
$this->em = $em;
$this->options = $options;
}
@@ -60,7 +60,7 @@ class UrlShortener implements UrlShortenerInterface
// If the URL validation is enabled, check that the URL actually exists
if ($this->options->isUrlValidationEnabled()) {
$this->checkUrlExists($url);
$this->urlValidator->validateUrl($url);
}
$this->em->beginTransaction();
@@ -110,17 +110,6 @@ class UrlShortener implements UrlShortenerInterface
});
}
private function checkUrlExists(string $url): void
{
try {
$this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [
RequestOptions::ALLOW_REDIRECTS => ['max' => 15],
]);
} catch (GuzzleException $e) {
throw InvalidUrlException::fromUrl($url, $e);
}
}
private function verifyShortCodeUniqueness(ShortUrlMeta $meta, ShortUrl $shortUrlToBeCreated): void
{
$shortCode = $shortUrlToBeCreated->getShortCode();

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Fig\Http\Message\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Zend\Diactoros\Uri;
use function Functional\contains;
use function idn_to_ascii;
use const IDNA_DEFAULT;
use const INTL_IDNA_VARIANT_UTS46;
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface, StatusCodeInterface
{
private const MAX_REDIRECTS = 15;
/** @var ClientInterface */
private $httpClient;
public function __construct(ClientInterface $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @throws InvalidUrlException
*/
public function validateUrl(string $url): void
{
$this->doValidateUrl($url);
}
/**
* @throws InvalidUrlException
*/
private function doValidateUrl(string $url, int $redirectNum = 1): void
{
// FIXME Guzzle is about to add support for this https://github.com/guzzle/guzzle/pull/2286
// Remove custom implementation and manual redirect handling when Guzzle's PR is merged
$uri = new Uri($url);
$originalHost = $uri->getHost();
$normalizedHost = idn_to_ascii($originalHost, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
if ($originalHost !== $normalizedHost) {
$uri = $uri->withHost($normalizedHost);
}
try {
$resp = $this->httpClient->request(self::METHOD_GET, (string) $uri, [
// RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
RequestOptions::ALLOW_REDIRECTS => false,
]);
if ($redirectNum < self::MAX_REDIRECTS && $this->statusIsRedirect($resp->getStatusCode())) {
$this->doValidateUrl($resp->getHeaderLine('Location'), $redirectNum + 1);
}
} catch (GuzzleException $e) {
throw InvalidUrlException::fromUrl($url, $e);
}
}
private function statusIsRedirect(int $statusCode): bool
{
return contains([self::STATUS_MOVED_PERMANENTLY, self::STATUS_FOUND], $statusCode);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
interface UrlValidatorInterface
{
/**
* @throws InvalidUrlException
*/
public function validateUrl(string $url): void;
}

View File

@@ -17,6 +17,8 @@ use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
use function array_key_exists;
class RedirectActionTest extends TestCase
{
/** @var RedirectAction */
@@ -38,23 +40,36 @@ class RedirectActionTest extends TestCase
);
}
/** @test */
public function redirectionIsPerformedToLongUrl(): void
/**
* @test
* @dataProvider provideQueries
*/
public function redirectionIsPerformedToLongUrl(string $expectedUrl, array $query): void
{
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
$shortCodeToUrl = $this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl);
$track = $this->visitTracker->track(Argument::cetera())->will(function () {
});
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode);
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withQueryParams($query);
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
$this->assertInstanceOf(Response\RedirectResponse::class, $response);
$this->assertEquals(302, $response->getStatusCode());
$this->assertTrue($response->hasHeader('Location'));
$this->assertEquals($expectedUrl, $response->getHeaderLine('Location'));
$shortCodeToUrl->shouldHaveBeenCalledOnce();
$track->shouldHaveBeenCalledTimes(array_key_exists('foobar', $query) ? 0 : 1);
}
public function provideQueries(): iterable
{
yield ['http://domain.com/foo/bar?some=thing', []];
yield ['http://domain.com/foo/bar?some=thing', ['foobar' => 'notrack']];
yield ['http://domain.com/foo/bar?some=thing&foo=bar', ['foo' => 'bar']];
yield ['http://domain.com/foo/bar?some=overwritten&foo=bar', ['foo' => 'bar', 'some' => 'overwritten']];
yield ['http://domain.com/foo/bar?some=overwritten', ['foobar' => 'notrack', 'some' => 'overwritten']];
}
/** @test */
@@ -73,24 +88,4 @@ class RedirectActionTest extends TestCase
$handle->shouldHaveBeenCalledOnce();
}
/** @test */
public function visitIsNotTrackedIfDisableParamIsProvided(): void
{
$shortCode = 'abc123';
$expectedUrl = 'http://domain.com/foo/bar';
$shortUrl = new ShortUrl($expectedUrl);
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl)
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)
->withQueryParams(['foobar' => true]);
$response = $this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
$this->assertInstanceOf(Response\RedirectResponse::class, $response);
$this->assertEquals(302, $response->getStatusCode());
$this->assertTrue($response->hasHeader('Location'));
$this->assertEquals($expectedUrl, $response->getHeaderLine('Location'));
}
}

View File

@@ -52,6 +52,7 @@ class SimplifiedConfigParserTest extends TestCase
'port' => '1234',
],
'base_path' => '/foo/bar',
'task_worker_num' => 50,
];
$expected = [
'app_options' => [
@@ -102,6 +103,14 @@ class SimplifiedConfigParserTest extends TestCase
'not_found_redirects' => [
'invalid_short_url' => 'foobar.com',
],
'zend-expressive-swoole' => [
'swoole-http-server' => [
'options' => [
'task_worker_num' => 50,
],
],
],
];
$result = ($this->postProcessor)(array_merge($config, $simplified));

View File

@@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
use stdClass;
class ShortUrlMetaTest extends TestCase
{
@@ -35,6 +36,14 @@ class ShortUrlMetaTest extends TestCase
ShortUrlMetaInputFilter::VALID_SINCE => '2017',
ShortUrlMetaInputFilter::MAX_VISITS => 5,
]];
yield [[
ShortUrlMetaInputFilter::VALID_SINCE => new stdClass(),
ShortUrlMetaInputFilter::VALID_UNTIL => 'foo',
]];
yield [[
ShortUrlMetaInputFilter::VALID_UNTIL => 500,
ShortUrlMetaInputFilter::DOMAIN => 4,
]];
}
/** @test */

View File

@@ -11,6 +11,7 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
@@ -88,22 +89,51 @@ class TagServiceTest extends TestCase
$this->service->renameTag('foo', 'bar');
}
/** @test */
public function renameValidTagChangesItsName()
/**
* @test
* @dataProvider provideValidRenames
*/
public function renameValidTagChangesItsName(string $oldName, string $newName, int $count): void
{
$expected = new Tag('foo');
$repo = $this->prophesize(TagRepository::class);
$find = $repo->findOneBy(Argument::cetera())->willReturn($expected);
$countTags = $repo->count(Argument::cetera())->willReturn($count);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$flush = $this->em->flush($expected)->willReturn(null);
$tag = $this->service->renameTag('foo', 'bar');
$tag = $this->service->renameTag($oldName, $newName);
$this->assertSame($expected, $tag);
$this->assertEquals('bar', (string) $tag);
$this->assertEquals($newName, (string) $tag);
$find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
$countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1);
}
public function provideValidRenames(): iterable
{
yield 'same names' => ['foo', 'foo', 1];
yield 'different names names' => ['foo', 'bar', 0];
}
/** @test */
public function renameTagToAnExistingNameThrowsException(): void
{
$repo = $this->prophesize(TagRepository::class);
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$countTags = $repo->count(Argument::cetera())->willReturn(1);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$flush = $this->em->flush(Argument::any())->willReturn(null);
$find->shouldBeCalled();
$getRepo->shouldBeCalled();
$countTags->shouldBeCalled();
$flush->shouldNotBeCalled();
$this->expectException(TagConflictException::class);
$this->service->renameTag('foo', 'bar');
}
}

View File

@@ -9,21 +9,18 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Psr7\Request;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
use Zend\Diactoros\Uri;
use function array_map;
@@ -35,11 +32,11 @@ class UrlShortenerTest extends TestCase
/** @var ObjectProphecy */
private $em;
/** @var ObjectProphecy */
private $httpClient;
private $urlValidator;
public function setUp(): void
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->urlValidator = $this->prophesize(UrlValidatorInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$conn = $this->prophesize(Connection::class);
@@ -63,7 +60,7 @@ class UrlShortenerTest extends TestCase
private function setUrlShortener(bool $urlValidationEnabled): void
{
$this->urlShortener = new UrlShortener(
$this->httpClient->reveal(),
$this->urlValidator->reveal(),
$this->em->reveal(),
new UrlShortenerOptions(['validate_url' => $urlValidationEnabled])
);
@@ -127,20 +124,19 @@ class UrlShortenerTest extends TestCase
}
/** @test */
public function exceptionIsThrownWhenUrlDoesNotExist(): void
public function validatorIsCalledWhenUrlValidationIsEnabled(): void
{
$this->setUrlShortener(true);
$validateUrl = $this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar')->will(function () {
});
$this->httpClient->request(Argument::cetera())->willThrow(
new ClientException('', $this->prophesize(Request::class)->reveal())
);
$this->expectException(InvalidUrlException::class);
$this->urlShortener->urlToShortCode(
new Uri('http://foobar.com/12345/hello?foo=bar'),
[],
ShortUrlMeta::createEmpty()
);
$validateUrl->shouldHaveBeenCalledOnce();
}
/** @test */

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Util;
use Fig\Http\Message\RequestMethodInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ClientException;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Util\UrlValidator;
use Zend\Diactoros\Request;
use Zend\Diactoros\Response;
use function Functional\map;
use function range;
class UrlValidatorTest extends TestCase
{
/** @var UrlValidator */
private $urlValidator;
/** @var ObjectProphecy */
private $httpClient;
public function setUp(): void
{
$this->httpClient = $this->prophesize(ClientInterface::class);
$this->urlValidator = new UrlValidator($this->httpClient->reveal());
}
/**
* @test
* @dataProvider provideAttemptThatThrows
*/
public function exceptionIsThrownWhenUrlIsInvalid(int $attemptThatThrows): void
{
$callNum = 1;
$e = new ClientException('', $this->prophesize(Request::class)->reveal());
$request = $this->httpClient->request(Argument::cetera())->will(
function () use ($e, $attemptThatThrows, &$callNum) {
if ($callNum === $attemptThatThrows) {
throw $e;
}
$callNum++;
return new Response('php://memory', 302, ['Location' => 'http://foo.com']);
}
);
$request->shouldBeCalledTimes($attemptThatThrows);
$this->expectException(InvalidUrlException::class);
$this->urlValidator->validateUrl('http://foobar.com/12345/hello?foo=bar');
}
public function provideAttemptThatThrows(): iterable
{
return map(range(1, 15), function (int $attempt) {
return [$attempt];
});
}
/**
* @test
* @dataProvider provideUrls
*/
public function expectedUrlIsCalledInOrderToVerifyProvidedUrl(string $providedUrl, string $expectedUrl): void
{
$request = $this->httpClient->request(
RequestMethodInterface::METHOD_GET,
$expectedUrl,
Argument::cetera()
)->willReturn(new Response());
$this->urlValidator->validateUrl($providedUrl);
$request->shouldHaveBeenCalledOnce();
}
public function provideUrls(): iterable
{
yield 'regular domain' => ['http://foobar.com', 'http://foobar.com'];
yield 'IDN' => ['https://tést.shlink.io', 'https://xn--tst-bma.shlink.io'];
}
/** @test */
public function considersUrlValidWhenTooManyRedirectsAreReturned(): void
{
$request = $this->httpClient->request(Argument::cetera())->willReturn(
new Response('php://memory', 302, ['Location' => 'http://foo.com'])
);
$this->urlValidator->validateUrl('http://foobar.com');
$request->shouldHaveBeenCalledTimes(15);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Cake\Chronos\Chronos;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
@@ -32,8 +31,8 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
try {
$meta = ShortUrlMeta::createFromParams(
$this->getOptionalDate($postData, 'validSince'),
$this->getOptionalDate($postData, 'validUntil'),
$postData['validSince'] ?? null,
$postData['validUntil'] ?? null,
$postData['customSlug'] ?? null,
$postData['maxVisits'] ?? null,
$postData['findIfExists'] ?? null,
@@ -45,9 +44,4 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
throw new InvalidArgumentException('Provided meta data is not valid', -1, $e);
}
}
private function getOptionalDate(array $postData, string $fieldName): ?Chronos
{
return isset($postData[$fieldName]) ? Chronos::parse($postData[$fieldName]) : null;
}
}

View File

@@ -8,6 +8,7 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
@@ -58,6 +59,15 @@ class UpdateTagAction extends AbstractRestAction
'error' => RestUtils::NOT_FOUND_ERROR,
'message' => sprintf('It was not possible to find a tag with name %s', $body['oldName']),
], self::STATUS_NOT_FOUND);
} catch (TagConflictException $e) {
return new JsonResponse([
'error' => 'TAG_CONFLICT',
'message' => sprintf(
'You cannot rename tag %s to %s, because it already exists',
$body['oldName'],
$body['newName']
),
], self::STATUS_CONFLICT);
}
}
}

View File

@@ -182,6 +182,25 @@ class CreateShortUrlActionTest extends ApiTestCase
$this->assertNotEquals($firstShortCode, $secondShortCode);
}
/**
* @test
* @dataProvider provideIdn
*/
public function createsNewShortUrlWithInternationalizedDomainName(string $longUrl): void
{
[$statusCode, $payload] = $this->createShortUrl(['longUrl' => $longUrl]);
$this->assertEquals(self::STATUS_OK, $statusCode);
$this->assertEquals($payload['longUrl'], $longUrl);
}
public function provideIdn(): iterable
{
yield ['http://tést.shlink.io']; // Redirects to https://shlink.io
yield ['http://test.shlink.io']; // Redirects to http://tést.shlink.io
yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io
}
/**
* @return array {
* @var int $statusCode

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
class UpdateTagActionTest extends ApiTestCase
{
/** @test */
public function errorIsThrownWhenTryingToRenameTagToAnotherTagName(): void
{
$resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [
'oldName' => 'foo',
'newName' => 'bar',
]]);
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_CONFLICT, $resp->getStatusCode());
$this->assertEquals('TAG_CONFLICT', $payload['error']);
}
/** @test */
public function tagIsProperlyRenamedWhenRenamingToItself(): void
{
$resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [
'oldName' => 'foo',
'newName' => 'foo',
]]);
$this->assertEquals(self::STATUS_NO_CONTENT, $resp->getStatusCode());
}
}

View File

@@ -4,3 +4,4 @@ parameters:
- '#ObjectManager::flush()#'
- '#Undefined variable: \$metadata#'
- '#AbstractQuery::setParameters()#'
- '#mustRun()#'