mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-05 06:43:12 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3479bbbb36 | ||
|
|
e1a1a0652f | ||
|
|
3e9b775114 | ||
|
|
57c91aca3c | ||
|
|
05a64b8d9e | ||
|
|
30780f9c5f | ||
|
|
3455df9214 | ||
|
|
27aa8f9875 | ||
|
|
05451e3d1a | ||
|
|
b9b3295b52 | ||
|
|
f62ed66e26 | ||
|
|
e2a9a989ab | ||
|
|
4af27650cd | ||
|
|
76a603104d | ||
|
|
115ca0da0f | ||
|
|
673b545a83 | ||
|
|
d030fd1aa6 | ||
|
|
7c1e40be88 | ||
|
|
b739619532 | ||
|
|
372b83d92f | ||
|
|
4e3b5419d5 | ||
|
|
c34d5a35e2 | ||
|
|
a959b5bf02 | ||
|
|
45ac2c3c51 | ||
|
|
f6bddc6f24 | ||
|
|
6b8fc3228e | ||
|
|
8cf1a95df5 | ||
|
|
b3ea2969c5 | ||
|
|
054bbb8d5a | ||
|
|
19c1b29f59 | ||
|
|
264b8c2a9e | ||
|
|
ec33b95f97 | ||
|
|
4437d5305f | ||
|
|
f20f01e22e | ||
|
|
1ee30fe5dc | ||
|
|
4dc026d7fc | ||
|
|
1e862a8ee8 | ||
|
|
5ece2d1939 | ||
|
|
146e9100be | ||
|
|
0c854edc6b | ||
|
|
07d031e7b9 | ||
|
|
705dc2ec39 | ||
|
|
3b9221c7d2 | ||
|
|
576d602ed0 | ||
|
|
9df8bd63d4 | ||
|
|
99c4802367 | ||
|
|
94dc6f2053 | ||
|
|
d4005da35c | ||
|
|
cbe2c362d5 | ||
|
|
8bf79db66a | ||
|
|
b87964f716 | ||
|
|
b0a574f578 | ||
|
|
92dc3019de | ||
|
|
d8f92cb2be |
@@ -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;'
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => [
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
11
migrations.php
Normal 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',
|
||||
];
|
||||
@@ -1,5 +0,0 @@
|
||||
name: ShlinkMigrations
|
||||
migrations_namespace: ShlinkMigrations
|
||||
table_name: migrations
|
||||
migrations_directory: data/migrations
|
||||
custom_template: data/migrations_template.txt
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
15
module/Core/src/Exception/TagConflictException.php
Normal file
15
module/Core/src/Exception/TagConflictException.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
73
module/Core/src/Util/UrlValidator.php
Normal file
73
module/Core/src/Util/UrlValidator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
15
module/Core/src/Util/UrlValidatorInterface.php
Normal file
15
module/Core/src/Util/UrlValidatorInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
101
module/Core/test/Util/UrlValidatorTest.php
Normal file
101
module/Core/test/Util/UrlValidatorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
35
module/Rest/test-api/Action/UpdateTagActionTest.php
Normal file
35
module/Rest/test-api/Action/UpdateTagActionTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ parameters:
|
||||
- '#ObjectManager::flush()#'
|
||||
- '#Undefined variable: \$metadata#'
|
||||
- '#AbstractQuery::setParameters()#'
|
||||
- '#mustRun()#'
|
||||
|
||||
Reference in New Issue
Block a user