mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-28 17:08:58 +03:00
commit
e92d437456
210 changed files with 3344 additions and 2716 deletions
10
.env.dist
10
.env.dist
|
@ -1,10 +0,0 @@
|
||||||
# Application
|
|
||||||
APP_ENV=
|
|
||||||
SECRET_KEY=
|
|
||||||
SHORTENED_URL_SCHEMA=
|
|
||||||
SHORTENED_URL_HOSTNAME=
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_USER=
|
|
||||||
DB_PASSWORD=
|
|
||||||
DB_NAME=
|
|
1
.gitattributes
vendored
1
.gitattributes
vendored
|
@ -9,7 +9,6 @@
|
||||||
/module/PreviewGenerator/test-db export-ignore
|
/module/PreviewGenerator/test-db export-ignore
|
||||||
/module/Rest/test export-ignore
|
/module/Rest/test export-ignore
|
||||||
/module/Rest/test-api export-ignore
|
/module/Rest/test-api export-ignore
|
||||||
.env.dist export-ignore
|
|
||||||
.gitattributes export-ignore
|
.gitattributes export-ignore
|
||||||
.gitignore export-ignore
|
.gitignore export-ignore
|
||||||
.phpstorm.meta.php export-ignore
|
.phpstorm.meta.php export-ignore
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,7 +4,6 @@ build
|
||||||
composer.lock
|
composer.lock
|
||||||
composer.phar
|
composer.phar
|
||||||
vendor/
|
vendor/
|
||||||
.env
|
|
||||||
data/database.sqlite
|
data/database.sqlite
|
||||||
data/shlink-tests.db
|
data/shlink-tests.db
|
||||||
data/GeoLite2-City.mmdb
|
data/GeoLite2-City.mmdb
|
||||||
|
|
|
@ -7,11 +7,11 @@ branches:
|
||||||
php:
|
php:
|
||||||
- '7.2'
|
- '7.2'
|
||||||
- '7.3'
|
- '7.3'
|
||||||
- '7.4snapshot'
|
- '7.4'
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
allow_failures:
|
allow_failures:
|
||||||
- php: '7.4snapshot'
|
- php: '7.4'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- mysql
|
- mysql
|
||||||
|
@ -43,7 +43,8 @@ script:
|
||||||
|
|
||||||
after_success:
|
after_success:
|
||||||
- rm -f build/clover.xml
|
- rm -f build/clover.xml
|
||||||
- phpdbg -qrr vendor/bin/phpcov merge build --clover build/clover.xml
|
- wget https://phar.phpunit.de/phpcov-6.0.1.phar
|
||||||
|
- phpdbg -qrr phpcov-6.0.1.phar merge build --clover build/clover.xml
|
||||||
- wget https://scrutinizer-ci.com/ocular.phar
|
- wget https://scrutinizer-ci.com/ocular.phar
|
||||||
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
||||||
|
|
||||||
|
|
52
CHANGELOG.md
52
CHANGELOG.md
|
@ -4,6 +4,58 @@ 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).
|
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## 1.21.0 - 2019-12-29
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
|
||||||
|
* [#118](https://github.com/shlinkio/shlink/issues/118) API errors now implement the [problem details](https://tools.ietf.org/html/rfc7807) standard.
|
||||||
|
|
||||||
|
In order to make it backwards compatible, two things have been done:
|
||||||
|
|
||||||
|
* Both the old `error` and `message` properties have been kept on error response, containing the same values as the `type` and `detail` properties respectively.
|
||||||
|
* The API `v2` has been enabled. If an error occurs when calling the API with this version, the `error` and `message` properties will not be returned.
|
||||||
|
|
||||||
|
> After Shlink v2 is released, both API versions will behave like API v2.
|
||||||
|
|
||||||
|
* [#575](https://github.com/shlinkio/shlink/issues/575) Added support to filter short URL lists by date ranges.
|
||||||
|
|
||||||
|
* The `GET /short-urls` endpoint now accepts the `startDate` and `endDate` query params.
|
||||||
|
* The `short-urls:list` command now allows `--startDate` and `--endDate` flags to be optionally provided.
|
||||||
|
|
||||||
|
* [#338](https://github.com/shlinkio/shlink/issues/338) Added support to asynchronously notify external services via webhook, only when shlink is served with swoole.
|
||||||
|
|
||||||
|
Configured webhooks will receive a POST request every time a URL receives a visit, including information about the short URL and the visit.
|
||||||
|
|
||||||
|
The payload will look like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shortUrl": {},
|
||||||
|
"visit": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> The `shortUrl` and `visit` props have the same shape as it is defined in the [API spec](https://api-spec.shlink.io).
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
* [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php.
|
||||||
|
* [#527](https://github.com/shlinkio/shlink/issues/527) Increased minimum required mutation score for unit tests to 80%.
|
||||||
|
* [#557](https://github.com/shlinkio/shlink/issues/557) Added a few php.ini configs for development and production docker images.
|
||||||
|
|
||||||
|
#### Deprecated
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
* [#570](https://github.com/shlinkio/shlink/issues/570) Fixed shlink version generated for docker images when building from `develop` branch.
|
||||||
|
|
||||||
|
|
||||||
## 1.20.3 - 2019-12-23
|
## 1.20.3 - 2019-12-23
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
FROM php:7.3.11-alpine3.10
|
FROM php:7.3.11-alpine3.10
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
|
|
||||||
ARG SHLINK_VERSION=1.20.0
|
ARG SHLINK_VERSION=1.20.2
|
||||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||||
ENV SWOOLE_VERSION 4.4.12
|
ENV SWOOLE_VERSION 4.4.12
|
||||||
ENV COMPOSER_VERSION 1.9.1
|
ENV COMPOSER_VERSION 1.9.1
|
||||||
|
@ -52,5 +52,6 @@ VOLUME /etc/shlink/config/params
|
||||||
# Copy config specific for the image
|
# Copy config specific for the image
|
||||||
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
|
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
|
||||||
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
|
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
|
||||||
|
COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]
|
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]
|
||||||
|
|
321
README.md
321
README.md
|
@ -1,4 +1,4 @@
|
||||||
# Shlink
|
![Shlink](https://raw.githubusercontent.com/shlinkio/shlink.io/master/public/images/shlink-hero.png)
|
||||||
|
|
||||||
[![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink)
|
[![Build Status](https://img.shields.io/travis/shlinkio/shlink.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink)
|
||||||
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||||
|
@ -12,26 +12,36 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
|
- [Download](#download)
|
||||||
|
- [Configure](#configure)
|
||||||
|
- [Serve](#serve)
|
||||||
|
- [Bonus](#bonus)
|
||||||
- [Update to new version](#update-to-new-version)
|
- [Update to new version](#update-to-new-version)
|
||||||
- [Using a docker image](#using-a-docker-image)
|
- [Using a docker image](#using-a-docker-image)
|
||||||
- [Using shlink](#using-shlink)
|
- [Using shlink](#using-shlink)
|
||||||
- [Shlink CLI Help](#shlink-cli-help)
|
- [Shlink CLI Help](#shlink-cli-help)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
First make sure the host where you are going to run shlink fulfills these requirements:
|
> These are the steps needed to install Shlink if you plan to manually host it.
|
||||||
|
>
|
||||||
|
> Alternatively, you can use the official docker image. If that's your intention, jump directly to [Using a docker image](#using-a-docker-image)
|
||||||
|
|
||||||
|
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||||
|
|
||||||
* PHP 7.2 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
|
* PHP 7.2 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
|
||||||
* MySQL, MariaDB, PostgreSQL or SQLite.
|
* MySQL, MariaDB, PostgreSQL or SQLite.
|
||||||
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
||||||
|
|
||||||
Then, you will need a built version of the project. There are a few ways to get it.
|
### Download
|
||||||
|
|
||||||
|
In order to run Shlink, you will need a built version of the project. There are two ways to get it.
|
||||||
|
|
||||||
* **Using a dist file**
|
* **Using a dist file**
|
||||||
|
|
||||||
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
|
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
|
||||||
|
|
||||||
Just go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink_X.X.X_dist.zip` file you will find there.
|
Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink_x.x.x_dist.zip` file you will find there.
|
||||||
|
|
||||||
Finally, decompress the file in the location of your choice.
|
Finally, decompress the file in the location of your choice.
|
||||||
|
|
||||||
|
@ -43,158 +53,159 @@ Then, you will need a built version of the project. There are a few ways to get
|
||||||
* Download the [Composer](https://getcomposer.org/download/) PHP package manager inside the project folder.
|
* Download the [Composer](https://getcomposer.org/download/) PHP package manager inside the project folder.
|
||||||
* Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is only used for the generated dist file).
|
* Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is only used for the generated dist file).
|
||||||
|
|
||||||
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory.
|
After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice.
|
||||||
|
|
||||||
This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching generated dist file to it.
|
> This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by [travis](https://travis-ci.org/shlinkio/shlink), attaching the generated dist file to it.
|
||||||
|
|
||||||
Despite how you built the project, you are going to need to install it now, by following these steps:
|
### Configure
|
||||||
|
|
||||||
|
Despite how you built the project, you now need to configure it, by following these steps:
|
||||||
|
|
||||||
* If you are going to use MySQL, MariaDB or PostgreSQL, create an empty database with the name of your choice.
|
* If you are going to use MySQL, MariaDB or PostgreSQL, create an empty database with the name of your choice.
|
||||||
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
|
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
|
||||||
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
|
* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
|
||||||
* Expose shlink to the web, either by using a traditional web server + fast CGI approach, or by using a [swoole](https://www.swoole.co.uk/) non-blocking server.
|
|
||||||
|
|
||||||
* **Using a web server:**
|
|
||||||
|
|
||||||
For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, these would be the basic configurations for Nginx and Apache.
|
|
||||||
|
|
||||||
*Nginx:*
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
server_name doma.in;
|
|
||||||
listen 80;
|
|
||||||
root /path/to/shlink/public;
|
|
||||||
index index.php;
|
|
||||||
charset utf-8;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.php$is_args$args;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
|
||||||
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
|
|
||||||
fastcgi_index index.php;
|
|
||||||
include fastcgi.conf;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ /\.ht {
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
*Apache:*
|
|
||||||
|
|
||||||
```apache
|
|
||||||
<VirtualHost *:80>
|
|
||||||
ServerName doma.in
|
|
||||||
DocumentRoot "/path/to/shlink/public"
|
|
||||||
|
|
||||||
<Directory "/path/to/shlink/public">
|
|
||||||
Options FollowSymLinks Includes ExecCGI
|
|
||||||
AllowOverride all
|
|
||||||
Order allow,deny
|
|
||||||
Allow from all
|
|
||||||
</Directory>
|
|
||||||
</VirtualHost>
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Using swoole:**
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
However, by doing it this way, you are loosing all the access logs, and the service won't be automatically run if the server has to be restarted.
|
|
||||||
|
|
||||||
For that reason, you should create a daemon script, in `/etc/init.d/shlink_swoole`, like this one, replacing `/path/to/shlink` by the path to your shlink installation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
### BEGIN INIT INFO
|
|
||||||
# Provides: shlink_swoole
|
|
||||||
# Required-Start: $local_fs $network $named $time $syslog
|
|
||||||
# Required-Stop: $local_fs $network $named $time $syslog
|
|
||||||
# Default-Start: 2 3 4 5
|
|
||||||
# Default-Stop: 0 1 6
|
|
||||||
# Description: Shlink non-blocking server with swoole
|
|
||||||
### END INIT INFO
|
|
||||||
|
|
||||||
SCRIPT=/path/to/shlink/vendor/bin/zend-expressive-swoole\ start
|
|
||||||
RUNAS=root
|
|
||||||
|
|
||||||
PIDFILE=/var/run/shlink_swoole.pid
|
|
||||||
LOGDIR=/var/log/shlink
|
|
||||||
LOGFILE=${LOGDIR}/shlink_swoole.log
|
|
||||||
|
|
||||||
start() {
|
|
||||||
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
|
|
||||||
echo 'Shlink with swoole already running' >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo 'Starting shlink with swoole' >&2
|
|
||||||
mkdir -p "$LOGDIR"
|
|
||||||
touch "$LOGFILE"
|
|
||||||
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
|
|
||||||
su -c "$CMD" $RUNAS > "$PIDFILE"
|
|
||||||
echo 'Shlink started' >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
|
|
||||||
echo 'Shlink with swoole not running' >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo 'Stopping shlink with swoole' >&2
|
|
||||||
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
|
|
||||||
echo 'Shlink stopped' >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
case "$1" in
|
|
||||||
start)
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
stop
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
stop
|
|
||||||
start
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 {start|stop|restart}"
|
|
||||||
esac
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run these commands to enable the service and start it:
|
|
||||||
|
|
||||||
* `sudo chmod +x /etc/init.d/shlink_swoole`
|
|
||||||
* `sudo update-rc.d shlink_swoole defaults`
|
|
||||||
* `sudo update-rc.d shlink_swoole enable`
|
|
||||||
* `/etc/init.d/shlink_swoole start`
|
|
||||||
|
|
||||||
Now again, you can access shlink on port 8080, but this time the service will be automatically run at system start-up, and all access logs will be written in `/var/log/shlink/shlink_swoole.log` (you will probably want to [rotate those logs](https://www.digitalocean.com/community/tutorials/how-to-manage-logfiles-with-logrotate-on-ubuntu-16-04). You can find an example logrotate config file [here](data/infra/examples/shlink-daemon-logrotate.conf)).
|
|
||||||
|
|
||||||
* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
|
* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
|
||||||
* Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs.
|
|
||||||
|
|
||||||
**Bonus**
|
### Serve
|
||||||
|
|
||||||
There are a couple of time-consuming tasks that shlink expects you to do manually, or at least it is recommended, since it will improve runtime performance.
|
Once Shlink is configured, you need to expose it to the web, either by using a traditional web server + fast CGI approach, or by using a [swoole](https://www.swoole.co.uk/) non-blocking server.
|
||||||
|
|
||||||
Those tasks can be performed using shlink's CLI, so it should be easy to schedule them to be run in the background (for example, using cron jobs):
|
* **Using a web server:**
|
||||||
|
|
||||||
* **For shlink older than 1.18.0 or not using swoole as the web server**: Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate`
|
For example, assuming your domain is doma.in and shlink is in the `/path/to/shlink` folder, these would be the basic configurations for Nginx and Apache.
|
||||||
|
|
||||||
|
*Nginx:*
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name doma.in;
|
||||||
|
listen 80;
|
||||||
|
root /path/to/shlink/public;
|
||||||
|
index index.php;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||||
|
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
include fastcgi.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.ht {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*Apache:*
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName doma.in
|
||||||
|
DocumentRoot "/path/to/shlink/public"
|
||||||
|
|
||||||
|
<Directory "/path/to/shlink/public">
|
||||||
|
Options FollowSymLinks Includes ExecCGI
|
||||||
|
AllowOverride all
|
||||||
|
Order allow,deny
|
||||||
|
Allow from all
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Using swoole:**
|
||||||
|
|
||||||
|
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. Run `./vendor/bin/zend-expressive-swoole start -d` and you will get shlink running on port 8080.
|
||||||
|
|
||||||
|
However, by doing it this way, you are loosing all the access logs, and the service won't be automatically run if the server has to be restarted.
|
||||||
|
|
||||||
|
For that reason, you should create a daemon script, in `/etc/init.d/shlink_swoole`, like this one, replacing `/path/to/shlink` by the path to your shlink installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
### BEGIN INIT INFO
|
||||||
|
# Provides: shlink_swoole
|
||||||
|
# Required-Start: $local_fs $network $named $time $syslog
|
||||||
|
# Required-Stop: $local_fs $network $named $time $syslog
|
||||||
|
# Default-Start: 2 3 4 5
|
||||||
|
# Default-Stop: 0 1 6
|
||||||
|
# Description: Shlink non-blocking server with swoole
|
||||||
|
### END INIT INFO
|
||||||
|
|
||||||
|
SCRIPT=/path/to/shlink/vendor/bin/zend-expressive-swoole\ start
|
||||||
|
RUNAS=root
|
||||||
|
|
||||||
|
PIDFILE=/var/run/shlink_swoole.pid
|
||||||
|
LOGDIR=/var/log/shlink
|
||||||
|
LOGFILE=${LOGDIR}/shlink_swoole.log
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
|
||||||
|
echo 'Shlink with swoole already running' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo 'Starting shlink with swoole' >&2
|
||||||
|
mkdir -p "$LOGDIR"
|
||||||
|
touch "$LOGFILE"
|
||||||
|
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
|
||||||
|
su -c "$CMD" $RUNAS > "$PIDFILE"
|
||||||
|
echo 'Shlink started' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
|
||||||
|
echo 'Shlink with swoole not running' >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo 'Stopping shlink with swoole' >&2
|
||||||
|
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
|
||||||
|
echo 'Shlink stopped' >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
stop
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {start|stop|restart}"
|
||||||
|
esac
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run these commands to enable the service and start it:
|
||||||
|
|
||||||
|
* `sudo chmod +x /etc/init.d/shlink_swoole`
|
||||||
|
* `sudo update-rc.d shlink_swoole defaults`
|
||||||
|
* `sudo update-rc.d shlink_swoole enable`
|
||||||
|
* `/etc/init.d/shlink_swoole start`
|
||||||
|
|
||||||
|
Now again, you can access shlink on port 8080, but this time the service will be automatically run at system start-up, and all access logs will be written in `/var/log/shlink/shlink_swoole.log` (you will probably want to [rotate those logs](https://www.digitalocean.com/community/tutorials/how-to-manage-logfiles-with-logrotate-on-ubuntu-16-04). You can find an example logrotate config file [here](data/infra/examples/shlink-daemon-logrotate.conf)).
|
||||||
|
|
||||||
|
Finally access to [https://app.shlink.io](https://app.shlink.io) and configure your server to start creating short URLs.
|
||||||
|
|
||||||
|
### Bonus
|
||||||
|
|
||||||
|
Depending on the shlink version you installed and how you serve it, there are a couple of time-consuming tasks that shlink expects you to do manually, or at least it is recommended, since it will improve runtime performance.
|
||||||
|
|
||||||
|
Those tasks can be performed using shlink's CLI tool, so it should be easy to schedule them to be run in the background (for example, using cron jobs):
|
||||||
|
|
||||||
|
* **For shlink older than 1.18.0 or not using swoole to serve it**: Resolve IP address locations: `/path/to/shlink/bin/cli visit:locate`
|
||||||
|
|
||||||
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
|
If you don't run this command regularly, the stats will say all visits come from *unknown* locations.
|
||||||
|
|
||||||
* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews`
|
> If you serve Shlink with swoole and use v1.18.0 at least, visit location is automatically scheduled by Shlink just after the visit occurs, using swoole's task system.
|
||||||
|
|
||||||
Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site.
|
|
||||||
|
|
||||||
> **Important!** Generating previews is considered deprecated and the feature will be removed in Shlink v2.
|
|
||||||
|
|
||||||
* **For shlink older than v1.17.0**: Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
|
* **For shlink older than v1.17.0**: Update IP geolocation database: `/path/to/shlink/bin/cli visit:update-db`
|
||||||
|
|
||||||
|
@ -202,24 +213,28 @@ Those tasks can be performed using shlink's CLI, so it should be easy to schedul
|
||||||
|
|
||||||
The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday.
|
The file is updated the first Tuesday of every month, so it should be enough running this command the first Wednesday.
|
||||||
|
|
||||||
*Any of these commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.*
|
> You don't need this if you use Shlink v1.17.0 or newer, since now it downloads/updates the geolocation database automatically just before trying to use it.
|
||||||
|
|
||||||
> In future versions, it is planed that, when using **swoole** to serve shlink, some of these tasks are automatically run without blocking the request and also, without having to configure cron jobs. Probably resolving IP locations and generating previews.
|
* Generate website previews: `/path/to/shlink/bin/cli short-url:process-previews`
|
||||||
|
|
||||||
|
Running this will improve the performance of the `doma.in/abc123/preview` URLs, which return a preview of the site.
|
||||||
|
|
||||||
|
> **Important!** Generating previews is considered deprecated and the feature will be removed in Shlink v2.
|
||||||
|
|
||||||
|
*Any of these commands accept the `-q` flag, which makes it not display any output. This is recommended when configuring the commands as cron jobs.*
|
||||||
|
|
||||||
## Update to new version
|
## Update to new version
|
||||||
|
|
||||||
When a new Shlink version is available, you don't need to repeat the entire process yourself. Instead, follow these steps:
|
When a new Shlink version is available, you don't need to repeat the entire process. Instead, follow these steps:
|
||||||
|
|
||||||
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`).
|
1. Rename your existing Shlink directory to something else (ie. `shlink` ---> `shlink-old`).
|
||||||
2. Download and extract the new version of Shlink, and set the directories name to that of the old version. (ie. `shlink`).
|
2. Download and extract the new version of Shlink, and set the directory name to that of the old version (ie. `shlink`).
|
||||||
3. Run the `bin/update` script in the new version's directory to migrate your configuration over.
|
3. Run the `bin/update` script in the new version's directory to migrate your configuration over. You will be asked to provide the path to the old instance (ie. `shlink-old`).
|
||||||
4. If you are using shlink with swoole, restart the service by running `/etc/init.d/shlink_swoole restart`.
|
4. If you are using shlink with swoole, restart the service by running `/etc/init.d/shlink_swoole restart`.
|
||||||
|
|
||||||
The `bin/update` script will ask you for the location from previous shlink version, and use it in order to import the configuration. It will then update the database and generate some assets shlink needs to work.
|
The `bin/update` will use the location from previous shlink version to import the configuration. It will then update the database and generate some assets shlink needs to work.
|
||||||
|
|
||||||
Right now, it does not import cached info (like website previews), but it will. For now you will need to regenerate them again.
|
**Important!** It is recommended that you don't skip any version when using this process. The update tool gets better on every version, but older versions might make assumptions.
|
||||||
|
|
||||||
**Important!** It is recommended that you don't skip any version when using this process. The update gets better on every version, but older versions might make assumptions.
|
|
||||||
|
|
||||||
## Using a docker image
|
## Using a docker image
|
||||||
|
|
||||||
|
@ -237,7 +252,7 @@ Once shlink is installed, there are two main ways to interact with it:
|
||||||
|
|
||||||
It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory.
|
It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory.
|
||||||
|
|
||||||
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found [here](https://shlink.io/swagger-ui/index.html).
|
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
|
||||||
|
|
||||||
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too.
|
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or you can host it yourself too.
|
||||||
|
|
||||||
|
|
9
bin/cli
9
bin/cli
|
@ -1,10 +1,7 @@
|
||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Psr\Container\ContainerInterface;
|
$run = require __DIR__ . '/../config/run.php';
|
||||||
use Symfony\Component\Console\Application as CliApp;
|
$run(true);
|
||||||
|
|
||||||
/** @var ContainerInterface $container */
|
|
||||||
$container = include __DIR__ . '/../config/container.php';
|
|
||||||
$container->get(CliApp::class)->run();
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ echo 'Starting server...'
|
||||||
vendor/bin/zend-expressive-swoole start -d
|
vendor/bin/zend-expressive-swoole start -d
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always
|
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||||
testsExitCode=$?
|
testsExitCode=$?
|
||||||
|
|
||||||
vendor/bin/zend-expressive-swoole stop
|
vendor/bin/zend-expressive-swoole stop
|
||||||
|
|
|
@ -15,34 +15,33 @@
|
||||||
"php": "^7.2",
|
"php": "^7.2",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-pdo": "*",
|
"ext-pdo": "*",
|
||||||
"acelaya/ze-content-based-error-handler": "^3.0",
|
|
||||||
"akrabat/ip-address-middleware": "^1.0",
|
"akrabat/ip-address-middleware": "^1.0",
|
||||||
"cakephp/chronos": "^1.2",
|
"cakephp/chronos": "^1.2",
|
||||||
"cocur/slugify": "^3.0",
|
"cocur/slugify": "^3.0",
|
||||||
"doctrine/cache": "^1.6",
|
"doctrine/cache": "^1.9",
|
||||||
"doctrine/dbal": "^2.9",
|
"doctrine/dbal": "^2.10",
|
||||||
"doctrine/migrations": "^2.0",
|
"doctrine/migrations": "^2.2",
|
||||||
"doctrine/orm": "^2.5",
|
"doctrine/orm": "^2.7",
|
||||||
"endroid/qr-code": "^3.6",
|
"endroid/qr-code": "^3.6",
|
||||||
"firebase/php-jwt": "^4.0",
|
"firebase/php-jwt": "^4.0",
|
||||||
"geoip2/geoip2": "^2.9",
|
"geoip2/geoip2": "^2.9",
|
||||||
"guzzlehttp/guzzle": "^6.3",
|
"guzzlehttp/guzzle": "^6.5.1",
|
||||||
"lstrojny/functional-php": "^1.9",
|
"lstrojny/functional-php": "^1.9",
|
||||||
"mikehaertl/phpwkhtmltopdf": "^2.2",
|
"mikehaertl/phpwkhtmltopdf": "^2.2",
|
||||||
"monolog/monolog": "^1.24",
|
"monolog/monolog": "^2.0",
|
||||||
|
"nikolaposa/monolog-factory": "^3.0",
|
||||||
"ocramius/proxy-manager": "~2.2.2",
|
"ocramius/proxy-manager": "~2.2.2",
|
||||||
"phly/phly-event-dispatcher": "^1.0",
|
"phly/phly-event-dispatcher": "^1.0",
|
||||||
"predis/predis": "^1.1",
|
"predis/predis": "^1.1",
|
||||||
"pugx/shortid-php": "^0.5",
|
"pugx/shortid-php": "^0.5",
|
||||||
"shlinkio/shlink-common": "^2.2.1",
|
"shlinkio/shlink-common": "^2.4",
|
||||||
"shlinkio/shlink-event-dispatcher": "^1.0",
|
"shlinkio/shlink-event-dispatcher": "^1.1",
|
||||||
"shlinkio/shlink-installer": "^3.1",
|
"shlinkio/shlink-installer": "^3.3",
|
||||||
"shlinkio/shlink-ip-geolocation": "^1.1",
|
"shlinkio/shlink-ip-geolocation": "^1.2",
|
||||||
"symfony/console": "^4.3",
|
"symfony/console": "^5.0",
|
||||||
"symfony/filesystem": "^4.3",
|
"symfony/filesystem": "^5.0",
|
||||||
"symfony/lock": "^4.3",
|
"symfony/lock": "^5.0",
|
||||||
"symfony/process": "^4.3",
|
"symfony/process": "^5.0",
|
||||||
"theorchard/monolog-cascade": "^0.5",
|
|
||||||
"zendframework/zend-config": "^3.3",
|
"zendframework/zend-config": "^3.3",
|
||||||
"zendframework/zend-config-aggregator": "^1.1",
|
"zendframework/zend-config-aggregator": "^1.1",
|
||||||
"zendframework/zend-diactoros": "^2.1.3",
|
"zendframework/zend-diactoros": "^2.1.3",
|
||||||
|
@ -53,24 +52,20 @@
|
||||||
"zendframework/zend-expressive-swoole": "^2.4",
|
"zendframework/zend-expressive-swoole": "^2.4",
|
||||||
"zendframework/zend-inputfilter": "^2.10",
|
"zendframework/zend-inputfilter": "^2.10",
|
||||||
"zendframework/zend-paginator": "^2.8",
|
"zendframework/zend-paginator": "^2.8",
|
||||||
|
"zendframework/zend-problem-details": "^1.0",
|
||||||
"zendframework/zend-servicemanager": "^3.4",
|
"zendframework/zend-servicemanager": "^3.4",
|
||||||
"zendframework/zend-stdlib": "^3.2"
|
"zendframework/zend-stdlib": "^3.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"devster/ubench": "^2.0",
|
"devster/ubench": "^2.0",
|
||||||
"eaglewu/swoole-ide-helper": "dev-master",
|
"eaglewu/swoole-ide-helper": "dev-master",
|
||||||
"filp/whoops": "^2.4",
|
"infection/infection": "^0.15.0",
|
||||||
"infection/infection": "^0.14.2",
|
"phpstan/phpstan-shim": "^0.11.16",
|
||||||
"phpstan/phpstan": "^0.11.16",
|
|
||||||
"phpunit/phpcov": "^6.0",
|
|
||||||
"phpunit/phpunit": "^8.3",
|
"phpunit/phpunit": "^8.3",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"shlinkio/php-coding-standard": "~2.0.0",
|
"shlinkio/php-coding-standard": "~2.0.0",
|
||||||
"shlinkio/shlink-test-utils": "^1.0",
|
"shlinkio/shlink-test-utils": "^1.2",
|
||||||
"symfony/dotenv": "^4.3",
|
"symfony/var-dumper": "^5.0"
|
||||||
"symfony/var-dumper": "^4.3",
|
|
||||||
"zendframework/zend-component-installer": "^2.1",
|
|
||||||
"zendframework/zend-expressive-tooling": "^1.2"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
@ -116,7 +111,7 @@
|
||||||
"@test:api"
|
"@test:api"
|
||||||
],
|
],
|
||||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||||
"test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/phpunit.junit.xml --testdox",
|
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
||||||
"test:db": [
|
"test:db": [
|
||||||
"@test:db:sqlite",
|
"@test:db:sqlite",
|
||||||
"@test:db:mysql",
|
"@test:db:mysql",
|
||||||
|
@ -128,19 +123,15 @@
|
||||||
"@test:db:mysql",
|
"@test:db:mysql",
|
||||||
"@test:db:postgres"
|
"@test:db:postgres"
|
||||||
],
|
],
|
||||||
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox",
|
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml",
|
||||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
||||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||||
"test:api": "bin/test/run-api-tests.sh",
|
"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",
|
"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": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||||
"infect:ci": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --coverage=build",
|
"infect:ci": "@infect --coverage=build",
|
||||||
"infect:show": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered --show-mutations",
|
"infect:show": "@infect --show-mutations",
|
||||||
"infect:test": [
|
"infect:test": [
|
||||||
"@test:unit:ci",
|
"@test:unit:ci",
|
||||||
"@infect:ci"
|
"@infect:ci"
|
||||||
|
@ -163,7 +154,6 @@
|
||||||
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
|
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
|
||||||
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
|
"test:db:postgres": "<fg=blue;options=bold>Runs database test suites on a PostgreSQL database</>",
|
||||||
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
|
"test:api": "<fg=blue;options=bold>Runs API test suites</>",
|
||||||
"test:pretty": "<fg=blue;options=bold>Runs all test suites and generates an HTML code coverage report</>",
|
|
||||||
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
|
"test:unit:pretty": "<fg=blue;options=bold>Runs unit test suites and generates an HTML code coverage report</>",
|
||||||
"infect": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing</>",
|
"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:ci": "<fg=blue;options=bold>Checks unit tests quality applying mutation testing with existing reports and logs</>",
|
||||||
|
|
|
@ -11,9 +11,9 @@ return [
|
||||||
'proxies_dir' => 'data/proxies',
|
'proxies_dir' => 'data/proxies',
|
||||||
],
|
],
|
||||||
'connection' => [
|
'connection' => [
|
||||||
'user' => env('DB_USER'),
|
'user' => '',
|
||||||
'password' => env('DB_PASSWORD'),
|
'password' => '',
|
||||||
'dbname' => env('DB_NAME', 'shlink'),
|
'dbname' => 'shlink',
|
||||||
'charset' => 'utf8',
|
'charset' => 'utf8',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'entity_manager' => [
|
'entity_manager' => [
|
||||||
'connection' => [
|
'connection' => [
|
||||||
|
'user' => 'root',
|
||||||
|
'password' => 'root',
|
||||||
'driver' => 'pdo_mysql',
|
'driver' => 'pdo_mysql',
|
||||||
'host' => 'shlink_db',
|
'host' => 'shlink_db',
|
||||||
'driverOptions' => [
|
'driverOptions' => [
|
||||||
|
|
34
config/autoload/error-handler.global.php
Normal file
34
config/autoload/error-handler.global.php
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\Common\Logger;
|
||||||
|
use Zend\ProblemDetails\ProblemDetailsMiddleware;
|
||||||
|
use Zend\Stratigility\Middleware\ErrorHandler;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'backwards_compatible_problem_details' => [
|
||||||
|
'default_type_fallbacks' => [
|
||||||
|
404 => 'NOT_FOUND',
|
||||||
|
500 => 'INTERNAL_SERVER_ERROR',
|
||||||
|
],
|
||||||
|
'json_flags' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION,
|
||||||
|
],
|
||||||
|
|
||||||
|
'error_handler' => [
|
||||||
|
'listeners' => [Logger\ErrorLogger::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'dependencies' => [
|
||||||
|
'delegators' => [
|
||||||
|
ErrorHandler::class => [
|
||||||
|
Logger\ErrorHandlerListenerAttachingDelegator::class,
|
||||||
|
],
|
||||||
|
ProblemDetailsMiddleware::class => [
|
||||||
|
Logger\ErrorHandlerListenerAttachingDelegator::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Zend\Expressive\Container\WhoopsErrorResponseGeneratorFactory;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'dependencies' => [
|
|
||||||
'invokables' => [
|
|
||||||
'Zend\Expressive\Whoops' => Whoops\Run::class,
|
|
||||||
'Zend\Expressive\WhoopsPageHandler' => Whoops\Handler\PrettyPageHandler::class,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'whoops' => [
|
|
||||||
'json_exceptions' => [
|
|
||||||
'display' => true,
|
|
||||||
'show_trace' => true,
|
|
||||||
'ajax_only' => true,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'error_handler' => [
|
|
||||||
'plugins' => [
|
|
||||||
'factories' => [
|
|
||||||
'text/html' => WhoopsErrorResponseGeneratorFactory::class,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
|
@ -11,6 +11,8 @@ return [
|
||||||
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
|
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
|
||||||
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
|
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
|
||||||
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
|
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
|
||||||
|
Plugin\UrlShortenerConfigCustomizer::NOTIFY_VISITS_WEBHOOKS,
|
||||||
|
Plugin\UrlShortenerConfigCustomizer::VISITS_WEBHOOKS,
|
||||||
],
|
],
|
||||||
|
|
||||||
Plugin\ApplicationConfigCustomizer::class => [
|
Plugin\ApplicationConfigCustomizer::class => [
|
||||||
|
|
|
@ -20,7 +20,7 @@ return [
|
||||||
'factories' => [
|
'factories' => [
|
||||||
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
|
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
|
||||||
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
|
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
|
||||||
Lock\Factory::class => ConfigAbstractFactory::class,
|
Lock\LockFactory::class => ConfigAbstractFactory::class,
|
||||||
$localLockFactory => ConfigAbstractFactory::class,
|
$localLockFactory => ConfigAbstractFactory::class,
|
||||||
],
|
],
|
||||||
'aliases' => [
|
'aliases' => [
|
||||||
|
@ -34,7 +34,7 @@ return [
|
||||||
Lock\Store\RedisStore::class => [
|
Lock\Store\RedisStore::class => [
|
||||||
RetryLockStoreDelegatorFactory::class,
|
RetryLockStoreDelegatorFactory::class,
|
||||||
],
|
],
|
||||||
Lock\Factory::class => [
|
Lock\LockFactory::class => [
|
||||||
LoggerAwareDelegatorFactory::class,
|
LoggerAwareDelegatorFactory::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
@ -43,7 +43,7 @@ return [
|
||||||
ConfigAbstractFactory::class => [
|
ConfigAbstractFactory::class => [
|
||||||
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
||||||
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
|
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
|
||||||
Lock\Factory::class => ['lock_store'],
|
Lock\LockFactory::class => ['lock_store'],
|
||||||
$localLockFactory => ['local_lock_store'],
|
$localLockFactory => ['local_lock_store'],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -4,64 +4,69 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink;
|
namespace Shlinkio\Shlink;
|
||||||
|
|
||||||
use Monolog\Handler\RotatingFileHandler;
|
use Monolog\Formatter;
|
||||||
use Monolog\Handler\StreamHandler;
|
use Monolog\Handler;
|
||||||
use Monolog\Logger;
|
use Monolog\Logger;
|
||||||
use Monolog\Processor;
|
use Monolog\Processor;
|
||||||
|
use MonologFactory\DiContainerLoggerFactory;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
use const PHP_EOL;
|
use const PHP_EOL;
|
||||||
|
|
||||||
|
$processors = [
|
||||||
|
'exception_with_new_line' => [
|
||||||
|
'name' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class,
|
||||||
|
],
|
||||||
|
'psr3' => [
|
||||||
|
'name' => Processor\PsrLogMessageProcessor::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$formatter = [
|
||||||
|
'name' => Formatter\LineFormatter::class,
|
||||||
|
'params' => [
|
||||||
|
'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL,
|
||||||
|
'allow_inline_line_breaks' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'logger' => [
|
'logger' => [
|
||||||
'formatters' => [
|
'Shlink' => [
|
||||||
'dashed' => [
|
'name' => 'Shlink',
|
||||||
'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL,
|
'handlers' => [
|
||||||
'include_stacktraces' => true,
|
'shlink_handler' => [
|
||||||
|
'name' => Handler\RotatingFileHandler::class,
|
||||||
|
'params' => [
|
||||||
|
'level' => Logger::INFO,
|
||||||
|
'filename' => 'data/log/shlink_log.log',
|
||||||
|
'max_files' => 30,
|
||||||
|
],
|
||||||
|
'formatter' => $formatter,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
'processors' => $processors,
|
||||||
],
|
],
|
||||||
|
'Access' => [
|
||||||
'handlers' => [
|
'name' => 'Access',
|
||||||
'shlink_rotating_handler' => [
|
'handlers' => [
|
||||||
'class' => RotatingFileHandler::class,
|
'access_handler' => [
|
||||||
'level' => Logger::INFO,
|
'name' => Handler\StreamHandler::class,
|
||||||
'filename' => 'data/log/shlink_log.log',
|
'params' => [
|
||||||
'max_files' => 30,
|
'level' => Logger::INFO,
|
||||||
'formatter' => 'dashed',
|
'stream' => 'php://stdout',
|
||||||
],
|
],
|
||||||
'access_handler' => [
|
'formatter' => $formatter,
|
||||||
'class' => StreamHandler::class,
|
],
|
||||||
'level' => Logger::INFO,
|
|
||||||
'stream' => 'php://stdout',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'processors' => [
|
|
||||||
'exception_with_new_line' => [
|
|
||||||
'class' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class,
|
|
||||||
],
|
|
||||||
'psr3' => [
|
|
||||||
'class' => Processor\PsrLogMessageProcessor::class,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'loggers' => [
|
|
||||||
'Shlink' => [
|
|
||||||
'handlers' => ['shlink_rotating_handler'],
|
|
||||||
'processors' => ['exception_with_new_line', 'psr3'],
|
|
||||||
],
|
|
||||||
'Access' => [
|
|
||||||
'handlers' => ['access_handler'],
|
|
||||||
'processors' => ['exception_with_new_line', 'psr3'],
|
|
||||||
],
|
],
|
||||||
|
'processors' => $processors,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'dependencies' => [
|
'dependencies' => [
|
||||||
'factories' => [
|
'factories' => [
|
||||||
'Logger_Shlink' => Common\Logger\LoggerFactory::class,
|
'Logger_Shlink' => [DiContainerLoggerFactory::class, 'Shlink'],
|
||||||
'Logger_Access' => Common\Logger\LoggerFactory::class,
|
'Logger_Access' => [DiContainerLoggerFactory::class, 'Access'],
|
||||||
],
|
],
|
||||||
'aliases' => [
|
'aliases' => [
|
||||||
'logger' => 'Logger_Shlink',
|
'logger' => 'Logger_Shlink',
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Monolog\Handler\StreamHandler;
|
use Monolog\Handler\StreamHandler;
|
||||||
|
@ -7,34 +8,28 @@ use Monolog\Logger;
|
||||||
$isSwoole = extension_loaded('swoole');
|
$isSwoole = extension_loaded('swoole');
|
||||||
|
|
||||||
// For swoole, send logs to standard output
|
// For swoole, send logs to standard output
|
||||||
$logger = $isSwoole ? [
|
$handler = $isSwoole
|
||||||
'handlers' => [
|
? [
|
||||||
'shlink_rotating_handler' => [
|
'name' => StreamHandler::class,
|
||||||
'level' => Logger::EMERGENCY, // This basically disables regular file logs
|
'params' => [
|
||||||
],
|
|
||||||
'shlink_stdout_handler' => [
|
|
||||||
'class' => StreamHandler::class,
|
|
||||||
'level' => Logger::DEBUG,
|
'level' => Logger::DEBUG,
|
||||||
'stream' => 'php://stdout',
|
'stream' => 'php://stdout',
|
||||||
'formatter' => 'dashed',
|
|
||||||
],
|
],
|
||||||
],
|
]
|
||||||
|
: [
|
||||||
'loggers' => [
|
'params' => [
|
||||||
'Shlink' => [
|
|
||||||
'handlers' => ['shlink_stdout_handler'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
] : [
|
|
||||||
'handlers' => [
|
|
||||||
'shlink_rotating_handler' => [
|
|
||||||
'level' => Logger::DEBUG,
|
'level' => Logger::DEBUG,
|
||||||
],
|
],
|
||||||
],
|
];
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'logger' => $logger,
|
'logger' => [
|
||||||
|
'Shlink' => [
|
||||||
|
'handlers' => [
|
||||||
|
'shlink_handler' => $handler,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -5,18 +5,31 @@ declare(strict_types=1);
|
||||||
namespace Shlinkio\Shlink;
|
namespace Shlinkio\Shlink;
|
||||||
|
|
||||||
use Zend\Expressive;
|
use Zend\Expressive;
|
||||||
|
use Zend\ProblemDetails;
|
||||||
use Zend\Stratigility\Middleware\ErrorHandler;
|
use Zend\Stratigility\Middleware\ErrorHandler;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'middleware_pipeline' => [
|
'middleware_pipeline' => [
|
||||||
|
'error-handler' => [
|
||||||
|
'middleware' => [
|
||||||
|
Expressive\Helper\ContentLengthMiddleware::class,
|
||||||
|
ErrorHandler::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'error-handler-rest' => [
|
||||||
|
'path' => '/rest',
|
||||||
|
'middleware' => [
|
||||||
|
Rest\Middleware\CrossDomainMiddleware::class,
|
||||||
|
Rest\Middleware\BackwardsCompatibleProblemDetailsMiddleware::class,
|
||||||
|
ProblemDetails\ProblemDetailsMiddleware::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
'pre-routing' => [
|
'pre-routing' => [
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
ErrorHandler::class,
|
|
||||||
Expressive\Helper\ContentLengthMiddleware::class,
|
|
||||||
Common\Middleware\CloseDbConnectionMiddleware::class,
|
Common\Middleware\CloseDbConnectionMiddleware::class,
|
||||||
],
|
],
|
||||||
'priority' => 12,
|
|
||||||
],
|
],
|
||||||
'pre-routing-rest' => [
|
'pre-routing-rest' => [
|
||||||
'path' => '/rest',
|
'path' => '/rest',
|
||||||
|
@ -24,33 +37,40 @@ return [
|
||||||
Rest\Middleware\PathVersionMiddleware::class,
|
Rest\Middleware\PathVersionMiddleware::class,
|
||||||
Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class,
|
Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class,
|
||||||
],
|
],
|
||||||
'priority' => 11,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'routing' => [
|
'routing' => [
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Expressive\Router\Middleware\RouteMiddleware::class,
|
Expressive\Router\Middleware\RouteMiddleware::class,
|
||||||
],
|
],
|
||||||
'priority' => 10,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'rest' => [
|
'rest' => [
|
||||||
'path' => '/rest',
|
'path' => '/rest',
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Rest\Middleware\CrossDomainMiddleware::class,
|
|
||||||
Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
|
Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||||
Rest\Middleware\BodyParserMiddleware::class,
|
Rest\Middleware\BodyParserMiddleware::class,
|
||||||
Rest\Middleware\AuthenticationMiddleware::class,
|
Rest\Middleware\AuthenticationMiddleware::class,
|
||||||
],
|
],
|
||||||
'priority' => 5,
|
|
||||||
],
|
],
|
||||||
|
|
||||||
'post-routing' => [
|
'dispatch' => [
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
Expressive\Router\Middleware\DispatchMiddleware::class,
|
Expressive\Router\Middleware\DispatchMiddleware::class,
|
||||||
Core\Response\NotFoundHandler::class,
|
|
||||||
],
|
],
|
||||||
'priority' => 1,
|
],
|
||||||
|
|
||||||
|
'not-found-rest' => [
|
||||||
|
'path' => '/rest',
|
||||||
|
'middleware' => [
|
||||||
|
ProblemDetails\ProblemDetailsNotFoundHandler::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'not-found' => [
|
||||||
|
'middleware' => [
|
||||||
|
Core\ErrorHandler\NotFoundRedirectHandler::class,
|
||||||
|
Core\ErrorHandler\NotFoundTemplateHandler::class,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
@ -2,16 +2,15 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use function Shlinkio\Shlink\Common\env;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'url_shortener' => [
|
'url_shortener' => [
|
||||||
'domain' => [
|
'domain' => [
|
||||||
'schema' => env('SHORTENED_URL_SCHEMA', 'http'),
|
'schema' => 'https',
|
||||||
'hostname' => env('SHORTENED_URL_HOSTNAME'),
|
'hostname' => '',
|
||||||
],
|
],
|
||||||
'validate_url' => true,
|
'validate_url' => true,
|
||||||
|
'visits_webhooks' => [],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
14
config/autoload/url-shortener.local.php.dist
Normal file
14
config/autoload/url-shortener.local.php.dist
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'url_shortener' => [
|
||||||
|
'domain' => [
|
||||||
|
'schema' => 'http',
|
||||||
|
'hostname' => 'localhost:8080',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
|
@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink;
|
namespace Shlinkio\Shlink;
|
||||||
|
|
||||||
use Acelaya\ExpressiveErrorHandler;
|
|
||||||
use Zend\ConfigAggregator;
|
use Zend\ConfigAggregator;
|
||||||
use Zend\Expressive;
|
use Zend\Expressive;
|
||||||
|
use Zend\ProblemDetails;
|
||||||
|
|
||||||
use function Shlinkio\Shlink\Common\env;
|
use function Shlinkio\Shlink\Common\env;
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||||
Expressive\Router\FastRouteRouter\ConfigProvider::class,
|
Expressive\Router\FastRouteRouter\ConfigProvider::class,
|
||||||
Expressive\Plates\ConfigProvider::class,
|
Expressive\Plates\ConfigProvider::class,
|
||||||
Expressive\Swoole\ConfigProvider::class,
|
Expressive\Swoole\ConfigProvider::class,
|
||||||
ExpressiveErrorHandler\ConfigProvider::class,
|
ProblemDetails\ConfigProvider::class,
|
||||||
Common\ConfigProvider::class,
|
Common\ConfigProvider::class,
|
||||||
IpGeolocation\ConfigProvider::class,
|
IpGeolocation\ConfigProvider::class,
|
||||||
Core\ConfigProvider::class,
|
Core\ConfigProvider::class,
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Symfony\Component\Dotenv\Dotenv;
|
|
||||||
use Symfony\Component\Lock;
|
use Symfony\Component\Lock;
|
||||||
use Zend\ServiceManager\ServiceManager;
|
use Zend\ServiceManager\ServiceManager;
|
||||||
|
|
||||||
|
@ -10,19 +9,16 @@ chdir(dirname(__DIR__));
|
||||||
|
|
||||||
require 'vendor/autoload.php';
|
require 'vendor/autoload.php';
|
||||||
|
|
||||||
// If the Dotenv class exists, load env vars and enable errors
|
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
|
||||||
if (class_exists(Dotenv::class)) {
|
if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) {
|
||||||
error_reporting(E_ALL);
|
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
|
||||||
ini_set('display_errors', '1');
|
|
||||||
$dotenv = new Dotenv();
|
|
||||||
$dotenv->load(__DIR__ . '/../.env');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
|
|
||||||
class_alias(Lock\Factory::class, 'Shlinkio\Shlink\LocalLockFactory');
|
|
||||||
|
|
||||||
// Build container
|
// Build container
|
||||||
$config = require __DIR__ . '/config.php';
|
return (function () {
|
||||||
$container = new ServiceManager($config['dependencies']);
|
$config = require __DIR__ . '/config.php';
|
||||||
$container->setService('config', $config);
|
$container = new ServiceManager($config['dependencies']);
|
||||||
return $container;
|
$container->setService('config', $config);
|
||||||
|
|
||||||
|
return $container;
|
||||||
|
})();
|
||||||
|
|
15
config/run.php
Normal file
15
config/run.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
use Symfony\Component\Console\Application as CliApp;
|
||||||
|
use Zend\Expressive\Application;
|
||||||
|
|
||||||
|
return function (bool $isCli = false): void {
|
||||||
|
/** @var ContainerInterface $container */
|
||||||
|
$container = include __DIR__ . '/container.php';
|
||||||
|
$app = $container->get($isCli ? CliApp::class : Application::class);
|
||||||
|
|
||||||
|
$app->run();
|
||||||
|
};
|
|
@ -7,14 +7,6 @@ namespace Shlinkio\Shlink\TestUtils;
|
||||||
use Doctrine\ORM\EntityManager;
|
use Doctrine\ORM\EntityManager;
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
use function file_exists;
|
|
||||||
use function touch;
|
|
||||||
|
|
||||||
// Create an empty .env file
|
|
||||||
if (! file_exists('.env')) {
|
|
||||||
touch('.env');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var ContainerInterface $container */
|
/** @var ContainerInterface $container */
|
||||||
$container = require __DIR__ . '/../container.php';
|
$container = require __DIR__ . '/../container.php';
|
||||||
$testHelper = $container->get(Helper\TestHelper::class);
|
$testHelper = $container->get(Helper\TestHelper::class);
|
||||||
|
|
|
@ -6,14 +6,6 @@ namespace Shlinkio\Shlink\TestUtils;
|
||||||
|
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
|
|
||||||
use function file_exists;
|
|
||||||
use function touch;
|
|
||||||
|
|
||||||
// Create an empty .env file
|
|
||||||
if (! file_exists('.env')) {
|
|
||||||
touch('.env');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var ContainerInterface $container */
|
/** @var ContainerInterface $container */
|
||||||
$container = require __DIR__ . '/../container.php';
|
$container = require __DIR__ . '/../container.php';
|
||||||
$container->get(Helper\TestHelper::class)->createTestDb();
|
$container->get(Helper\TestHelper::class)->createTestDb();
|
||||||
|
|
|
@ -1 +1,6 @@
|
||||||
date.timezone = Europe/Madrid
|
display_errors=On
|
||||||
|
error_reporting=-1
|
||||||
|
memory_limit=-1
|
||||||
|
log_errors_max_len=0
|
||||||
|
zend.assertions=1
|
||||||
|
assert.exception=1
|
||||||
|
|
|
@ -3,7 +3,7 @@ version: '3'
|
||||||
services:
|
services:
|
||||||
shlink_nginx:
|
shlink_nginx:
|
||||||
container_name: shlink_nginx
|
container_name: shlink_nginx
|
||||||
image: nginx:1.15.9-alpine
|
image: nginx:1.17.6-alpine
|
||||||
ports:
|
ports:
|
||||||
- "8000:80"
|
- "8000:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -37,6 +37,7 @@ services:
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink
|
- ./:/home/shlink
|
||||||
|
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
|
||||||
links:
|
links:
|
||||||
- shlink_db
|
- shlink_db
|
||||||
- shlink_db_postgres
|
- shlink_db_postgres
|
||||||
|
|
|
@ -19,7 +19,7 @@ It also expects these two env vars to be provided, in order to properly generate
|
||||||
So based on this, to run shlink on a local docker service, you should run a command like this:
|
So based on this, to run shlink on a local docker service, you should run a command like this:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink
|
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interact with shlink's CLI on a running container.
|
### Interact with shlink's CLI on a running container.
|
||||||
|
@ -73,13 +73,13 @@ It is possible to use a set of env vars to make this shlink instance interact wi
|
||||||
Taking this into account, you could run shlink on a local docker service like this:
|
Taking this into account, you could run shlink on a local docker service like this:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink
|
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
You could even link to a local database running on a different container:
|
You could even link to a local database running on a different container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink
|
docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
> If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first.
|
> If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first.
|
||||||
|
@ -110,6 +110,7 @@ This is the complete list of supported env vars:
|
||||||
* `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 `''`.
|
* `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.
|
* `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.
|
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
|
||||||
|
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
|
||||||
* `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).
|
* `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.
|
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.
|
||||||
|
@ -145,7 +146,8 @@ docker run \
|
||||||
-e "BASE_PATH=/my-campaign" \
|
-e "BASE_PATH=/my-campaign" \
|
||||||
-e WEB_WORKER_NUM=64 \
|
-e WEB_WORKER_NUM=64 \
|
||||||
-e TASK_WORKER_NUM=32 \
|
-e TASK_WORKER_NUM=32 \
|
||||||
shlinkio/shlink
|
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
||||||
|
shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
## Provide config via volumes
|
## Provide config via volumes
|
||||||
|
@ -173,6 +175,10 @@ The whole configuration should have this format, but it can be split into multip
|
||||||
"tcp://172.20.0.1:6379",
|
"tcp://172.20.0.1:6379",
|
||||||
"tcp://172.20.0.2:6379"
|
"tcp://172.20.0.2:6379"
|
||||||
],
|
],
|
||||||
|
"visits_webhooks": [
|
||||||
|
"http://my-api.com/api/v2.3/notify",
|
||||||
|
"https://third-party.io/foo"
|
||||||
|
],
|
||||||
"db_config": {
|
"db_config": {
|
||||||
"driver": "pdo_mysql",
|
"driver": "pdo_mysql",
|
||||||
"dbname": "shlink",
|
"dbname": "shlink",
|
||||||
|
@ -192,7 +198,7 @@ The whole configuration should have this format, but it can be split into multip
|
||||||
Once created just run shlink with the volume:
|
Once created just run shlink with the volume:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink
|
docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
## Multi instance considerations
|
## Multi instance considerations
|
||||||
|
|
3
docker/config/php.ini
Normal file
3
docker/config/php.ini
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
log_errors_max_len=0
|
||||||
|
zend.assertions=1
|
||||||
|
assert.exception=1
|
|
@ -99,6 +99,12 @@ $helper = new class {
|
||||||
'base_url' => env('BASE_URL_REDIRECT_TO'),
|
'base_url' => env('BASE_URL_REDIRECT_TO'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getVisitsWebhooks(): array
|
||||||
|
{
|
||||||
|
$webhooks = env('VISITS_WEBHOOKS');
|
||||||
|
return $webhooks === null ? [] : explode(',', $webhooks);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
@ -125,26 +131,21 @@ return [
|
||||||
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
||||||
],
|
],
|
||||||
'validate_url' => (bool) env('VALIDATE_URLS', true),
|
'validate_url' => (bool) env('VALIDATE_URLS', true),
|
||||||
|
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||||
],
|
],
|
||||||
|
|
||||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||||
|
|
||||||
'logger' => [
|
'logger' => [
|
||||||
'handlers' => [
|
'Shlink' => [
|
||||||
'shlink_rotating_handler' => [
|
'handlers' => [
|
||||||
'level' => Logger::EMERGENCY, // This basically disables regular file logs
|
'shlink_handler' => [
|
||||||
],
|
'name' => StreamHandler::class,
|
||||||
'shlink_stdout_handler' => [
|
'params' => [
|
||||||
'class' => StreamHandler::class,
|
'level' => Logger::INFO,
|
||||||
'level' => Logger::INFO,
|
'stream' => 'php://stdout',
|
||||||
'stream' => 'php://stdout',
|
],
|
||||||
'formatter' => 'dashed',
|
],
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
'loggers' => [
|
|
||||||
'Shlink' => [
|
|
||||||
'handlers' => ['shlink_stdout_handler'],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,13 +1,32 @@
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
"required": ["type", "title", "detail", "status"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "A machine unique code"
|
"description": "A machine unique code"
|
||||||
},
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A unique title"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A human-friendly error description"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "HTTP response status code"
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "**[Deprecated] Use type instead. Not returned for v2 of the REST API** A machine unique code",
|
||||||
|
"deprecated": true
|
||||||
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "A human-friendly error message"
|
"description": "**[Deprecated] Use detail instead. Not returned for v2 of the REST API** A human-friendly error message",
|
||||||
|
"deprecated": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
13
docs/swagger/parameters/version.json
Normal file
13
docs/swagger/parameters/version.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"name": "version",
|
||||||
|
"description": "The API version to be consumed",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"2",
|
||||||
|
"1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,9 @@
|
||||||
"summary": "List short URLs",
|
"summary": "List short URLs",
|
||||||
"description": "Returns the list of short URLs.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
"description": "Returns the list of short URLs.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "page",
|
"name": "page",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
@ -51,6 +54,24 @@
|
||||||
"visits"
|
"visits"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "startDate",
|
||||||
|
"in": "query",
|
||||||
|
"description": "The date (in ISO-8601 format) from which we want to get short URLs.",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "endDate",
|
||||||
|
"in": "query",
|
||||||
|
"description": "The date (in ISO-8601 format) until which we want to get short URLs.",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": [
|
"security": [
|
||||||
|
@ -150,7 +171,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -175,6 +196,11 @@
|
||||||
"Bearer": []
|
"Bearer": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"description": "Request body.",
|
"description": "Request body.",
|
||||||
"required": true,
|
"required": true,
|
||||||
|
@ -256,11 +282,43 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "The long URL was not provided or is invalid.",
|
"description": "Some of provided data is invalid. Check extra fields to know exactly what.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "../definitions/Error.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"invalidElements": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"validSince",
|
||||||
|
"validUntil",
|
||||||
|
"customSlug",
|
||||||
|
"maxVisits",
|
||||||
|
"findIfExists",
|
||||||
|
"domain"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A URL that could not be verified, if the error type is INVALID_URL"
|
||||||
|
},
|
||||||
|
"customSlug": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Provided custom slug when the error type is INVALID_SLUG"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -268,7 +326,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
"summary": "Create a short URL",
|
"summary": "Create a short URL",
|
||||||
"description": "Creates a short URL in a single API call. Useful for third party integrations.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
"description": "Creates a short URL in a single API call. Useful for third party integrations.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "apiKey",
|
"name": "apiKey",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
@ -77,7 +80,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"description": "The long URL was not provided or is invalid.",
|
"description": "The long URL was not provided or is invalid.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -89,9 +92,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"error": "INVALID_URL",
|
"title": "Invalid URL",
|
||||||
"message": "Provided URL foo is invalid. Try with a different one."
|
"type": "INVALID_URL",
|
||||||
|
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||||
|
"status": 400,
|
||||||
|
"url": "https://invalid-url.com"
|
||||||
},
|
},
|
||||||
"text/plain": "INVALID_URL"
|
"text/plain": "INVALID_URL"
|
||||||
}
|
}
|
||||||
|
@ -99,7 +105,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -111,11 +117,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"error": "UNKNOWN_ERROR",
|
"error": "INTERNAL_SERVER_ERROR",
|
||||||
"message": "Unexpected error occurred"
|
"message": "Unexpected error occurred"
|
||||||
},
|
},
|
||||||
"text/plain": "UNKNOWN_ERROR"
|
"text/plain": "INTERNAL_SERVER_ERROR"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
"summary": "Parse short code",
|
"summary": "Parse short code",
|
||||||
"description": "Get the long URL behind a short URL's short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
"description": "Get the long URL behind a short URL's short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shortCode",
|
"name": "shortCode",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
@ -62,20 +65,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
|
||||||
"description": "Provided shortCode does not match the character set currently used by the app to generate short codes.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "../definitions/Error.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
"404": {
|
||||||
"description": "No URL was found for provided short code.",
|
"description": "No URL was found for provided short code.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -85,7 +78,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -103,6 +96,9 @@
|
||||||
"summary": "Edit short URL",
|
"summary": "Edit short URL",
|
||||||
"description": "Update certain meta arguments from an existing short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
"description": "Update certain meta arguments from an existing short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shortCode",
|
"name": "shortCode",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
@ -153,9 +149,31 @@
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Provided meta arguments are invalid.",
|
"description": "Provided meta arguments are invalid.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "../definitions/Error.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["invalidElements"],
|
||||||
|
"properties": {
|
||||||
|
"invalidElements": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"validSince",
|
||||||
|
"validUntil",
|
||||||
|
"maxVisits"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -163,7 +181,7 @@
|
||||||
"404": {
|
"404": {
|
||||||
"description": "No short URL was found for provided short code.",
|
"description": "No short URL was found for provided short code.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -173,7 +191,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -192,6 +210,9 @@
|
||||||
"summary": "[DEPRECATED] Edit short URL",
|
"summary": "[DEPRECATED] Edit short URL",
|
||||||
"description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead",
|
"description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shortCode",
|
"name": "shortCode",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
@ -242,7 +263,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Provided meta arguments are invalid.",
|
"description": "Provided meta arguments are invalid.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -252,7 +273,7 @@
|
||||||
"404": {
|
"404": {
|
||||||
"description": "No short URL was found for provided short code.",
|
"description": "No short URL was found for provided short code.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -262,7 +283,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -280,6 +301,9 @@
|
||||||
"summary": "Delete short URL",
|
"summary": "Delete short URL",
|
||||||
"description": "Deletes the short URL for provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
"description": "Deletes the short URL for provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shortCode",
|
"name": "shortCode",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
@ -302,26 +326,28 @@
|
||||||
"204": {
|
"204": {
|
||||||
"description": "The short URL has been properly deleted."
|
"description": "The short URL has been properly deleted."
|
||||||
},
|
},
|
||||||
"400": {
|
"422": {
|
||||||
"description": "The visits threshold in shlink does not allow this short URL to be deleted.",
|
"description": "The visits threshold in shlink does not allow this short URL to be deleted.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"examples": {
|
"examples": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"error": "INVALID_SHORTCODE_DELETION",
|
"title": "Cannot delete short URL",
|
||||||
"message": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits."
|
"type": "INVALID_SHORTCODE_DELETION",
|
||||||
|
"detail": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits.",
|
||||||
|
"status": 422
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "No short URL was found for provided short code.",
|
"description": "No short URL was found for provided short code.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -331,7 +357,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
"summary": "Edit tags on short URL",
|
"summary": "Edit tags on short URL",
|
||||||
"description": "Edit the tags on URL identified by provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
"description": "Edit the tags on URL identified by provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shortCode",
|
"name": "shortCode",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
@ -78,7 +81,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"description": "The request body does not contain a \"tags\" param with array type.",
|
"description": "The request body does not contain a \"tags\" param with array type.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
"summary": "List visits for short URL",
|
"summary": "List visits for short URL",
|
||||||
"description": "Get the list of visits on the short URL behind provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
"description": "Get the list of visits on the short URL behind provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "shortCode",
|
"name": "shortCode",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
|
@ -132,7 +135,7 @@
|
||||||
"404": {
|
"404": {
|
||||||
"description": "The short code does not belong to any short URL.",
|
"description": "The short code does not belong to any short URL.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -142,7 +145,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,11 @@
|
||||||
"Bearer": []
|
"Bearer": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "The list of tags",
|
"description": "The list of tags",
|
||||||
|
@ -53,7 +58,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -78,6 +83,11 @@
|
||||||
"Bearer": []
|
"Bearer": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"description": "Request body.",
|
"description": "Request body.",
|
||||||
"required": true,
|
"required": true,
|
||||||
|
@ -140,7 +150,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -165,6 +175,11 @@
|
||||||
"Bearer": []
|
"Bearer": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"description": "Request body.",
|
"description": "Request body.",
|
||||||
"required": true,
|
"required": true,
|
||||||
|
@ -197,7 +212,7 @@
|
||||||
"400": {
|
"400": {
|
||||||
"description": "You have not provided either the oldName or the newName params.",
|
"description": "You have not provided either the oldName or the newName params.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -207,7 +222,17 @@
|
||||||
"404": {
|
"404": {
|
||||||
"description": "There's no tag found with the name provided in oldName param.",
|
"description": "There's no tag found with the name provided in oldName param.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "../definitions/Error.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "The name provided in newName param is already in use for another tag.",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -217,7 +242,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
@ -235,6 +260,9 @@
|
||||||
"summary": "Delete tags",
|
"summary": "Delete tags",
|
||||||
"description": "Deletes provided list of tags",
|
"description": "Deletes provided list of tags",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"$ref": "../parameters/version.json"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "tags[]",
|
"name": "tags[]",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
@ -263,7 +291,7 @@
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Unexpected error.",
|
"description": "Unexpected error.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/problem+json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "../definitions/Error.json"
|
"$ref": "../definitions/Error.json"
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,16 +20,6 @@
|
||||||
"responses": {
|
"responses": {
|
||||||
"302": {
|
"302": {
|
||||||
"description": "Visit properly tracked and redirected"
|
"description": "Visit properly tracked and redirected"
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Unexpected error.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "../definitions/Error.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,16 +29,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Unexpected error.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "../definitions/Error.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,16 +40,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Unexpected error.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "../definitions/Error.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,16 +28,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"500": {
|
|
||||||
"description": "Unexpected error.",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "../definitions/Error.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,24 +71,24 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"/rest/v1/short-urls": {
|
"/rest/v{version}/short-urls": {
|
||||||
"$ref": "paths/v1_short-urls.json"
|
"$ref": "paths/v1_short-urls.json"
|
||||||
},
|
},
|
||||||
"/rest/v1/short-urls/shorten": {
|
"/rest/v{version}/short-urls/shorten": {
|
||||||
"$ref": "paths/v1_short-urls_shorten.json"
|
"$ref": "paths/v1_short-urls_shorten.json"
|
||||||
},
|
},
|
||||||
"/rest/v1/short-urls/{shortCode}": {
|
"/rest/v{version}/short-urls/{shortCode}": {
|
||||||
"$ref": "paths/v1_short-urls_{shortCode}.json"
|
"$ref": "paths/v1_short-urls_{shortCode}.json"
|
||||||
},
|
},
|
||||||
"/rest/v1/short-urls/{shortCode}/tags": {
|
"/rest/v{version}/short-urls/{shortCode}/tags": {
|
||||||
"$ref": "paths/v1_short-urls_{shortCode}_tags.json"
|
"$ref": "paths/v1_short-urls_{shortCode}_tags.json"
|
||||||
},
|
},
|
||||||
|
|
||||||
"/rest/v1/tags": {
|
"/rest/v{version}/tags": {
|
||||||
"$ref": "paths/v1_tags.json"
|
"$ref": "paths/v1_tags.json"
|
||||||
},
|
},
|
||||||
|
|
||||||
"/rest/v1/short-urls/{shortCode}/visits": {
|
"/rest/v{version}/short-urls/{shortCode}/visits": {
|
||||||
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
|
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -ex
|
set -ex
|
||||||
|
|
||||||
if [[ ${SOURCE_BRANCH} == 'master' ]]; then
|
if [[ ${SOURCE_BRANCH} == 'develop' ]]; then
|
||||||
SHLINK_RELEASE='latest'
|
SHLINK_RELEASE='latest'
|
||||||
else
|
else
|
||||||
SHLINK_RELEASE=${SOURCE_BRANCH#?}
|
SHLINK_RELEASE=${SOURCE_BRANCH#?}
|
||||||
|
|
|
@ -15,7 +15,7 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||||
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator;
|
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator;
|
||||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||||
use Symfony\Component\Console as SymfonyCli;
|
use Symfony\Component\Console as SymfonyCli;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||||
use Zend\ServiceManager\Factory\InvokableFactory;
|
use Zend\ServiceManager\Factory\InvokableFactory;
|
||||||
|
@ -70,7 +70,7 @@ return [
|
||||||
Command\Visit\LocateVisitsCommand::class => [
|
Command\Visit\LocateVisitsCommand::class => [
|
||||||
Service\VisitService::class,
|
Service\VisitService::class,
|
||||||
IpLocationResolverInterface::class,
|
IpLocationResolverInterface::class,
|
||||||
Locker::class,
|
LockFactory::class,
|
||||||
GeolocationDbUpdater::class,
|
GeolocationDbUpdater::class,
|
||||||
],
|
],
|
||||||
Command\Visit\UpdateDbCommand::class => [DbUpdater::class],
|
Command\Visit\UpdateDbCommand::class => [DbUpdater::class],
|
||||||
|
@ -85,14 +85,14 @@ return [
|
||||||
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class],
|
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class],
|
||||||
|
|
||||||
Command\Db\CreateDatabaseCommand::class => [
|
Command\Db\CreateDatabaseCommand::class => [
|
||||||
Locker::class,
|
LockFactory::class,
|
||||||
SymfonyCli\Helper\ProcessHelper::class,
|
SymfonyCli\Helper\ProcessHelper::class,
|
||||||
PhpExecutableFinder::class,
|
PhpExecutableFinder::class,
|
||||||
Connection::class,
|
Connection::class,
|
||||||
NoDbNameConnectionFactory::SERVICE_NAME,
|
NoDbNameConnectionFactory::SERVICE_NAME,
|
||||||
],
|
],
|
||||||
Command\Db\MigrateDatabaseCommand::class => [
|
Command\Db\MigrateDatabaseCommand::class => [
|
||||||
Locker::class,
|
LockFactory::class,
|
||||||
SymfonyCli\Helper\ProcessHelper::class,
|
SymfonyCli\Helper\ProcessHelper::class,
|
||||||
PhpExecutableFinder::class,
|
PhpExecutableFinder::class,
|
||||||
],
|
],
|
||||||
|
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI\Command\Api;
|
namespace Shlinkio\Shlink\CLI\Command\Api;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
|
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||||
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
@ -45,7 +45,7 @@ class DisableKeyCommand extends Command
|
||||||
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
|
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
$io->error(sprintf('API key "%s" does not exist.', $apiKey));
|
$io->error($e->getMessage());
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ class GenerateKeyCommand extends Command
|
||||||
->addOption(
|
->addOption(
|
||||||
'expirationDate',
|
'expirationDate',
|
||||||
'e',
|
'e',
|
||||||
InputOption::VALUE_OPTIONAL,
|
InputOption::VALUE_REQUIRED,
|
||||||
'The date in which the API key should expire. Use any valid PHP format.'
|
'The date in which the API key should expire. Use any valid PHP format.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
use function array_unshift;
|
use function array_unshift;
|
||||||
|
@ -20,7 +20,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||||
/** @var string */
|
/** @var string */
|
||||||
private $phpBinary;
|
private $phpBinary;
|
||||||
|
|
||||||
public function __construct(Locker $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder)
|
public function __construct(LockFactory $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder)
|
||||||
{
|
{
|
||||||
parent::__construct($locker);
|
parent::__construct($locker);
|
||||||
$this->processHelper = $processHelper;
|
$this->processHelper = $processHelper;
|
||||||
|
|
|
@ -10,7 +10,7 @@ use Symfony\Component\Console\Helper\ProcessHelper;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
use function Functional\contains;
|
use function Functional\contains;
|
||||||
|
@ -27,7 +27,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
|
||||||
private $noDbNameConn;
|
private $noDbNameConn;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Locker $locker,
|
LockFactory $locker,
|
||||||
ProcessHelper $processHelper,
|
ProcessHelper $processHelper,
|
||||||
PhpExecutableFinder $phpFinder,
|
PhpExecutableFinder $phpFinder,
|
||||||
Connection $conn,
|
Connection $conn,
|
||||||
|
|
|
@ -55,22 +55,17 @@ class DeleteShortUrlCommand extends Command
|
||||||
try {
|
try {
|
||||||
$this->runDelete($io, $shortCode, $ignoreThreshold);
|
$this->runDelete($io, $shortCode, $ignoreThreshold);
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (Exception\InvalidShortCodeException $e) {
|
} catch (Exception\ShortUrlNotFoundException $e) {
|
||||||
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
|
$io->error($e->getMessage());
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
} catch (Exception\DeleteShortUrlException $e) {
|
} catch (Exception\DeleteShortUrlException $e) {
|
||||||
return $this->retry($io, $shortCode, $e);
|
return $this->retry($io, $shortCode, $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): int
|
private function retry(SymfonyStyle $io, string $shortCode, string $warningMsg): int
|
||||||
{
|
{
|
||||||
$warningMsg = sprintf(
|
$io->writeln(sprintf('<bg=yellow>%s</>', $warningMsg));
|
||||||
'It was not possible to delete the short URL with short code "%s" because it has more than %s visits.',
|
|
||||||
$shortCode,
|
|
||||||
$e->getVisitsThreshold()
|
|
||||||
);
|
|
||||||
$io->writeln('<bg=yellow>' . $warningMsg . '</>');
|
|
||||||
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
|
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
|
||||||
|
|
||||||
if ($forceDelete) {
|
if ($forceDelete) {
|
||||||
|
|
|
@ -69,7 +69,7 @@ class GeneratePreviewCommand extends Command
|
||||||
} catch (PreviewGenerationException $e) {
|
} catch (PreviewGenerationException $e) {
|
||||||
$output->writeln(' <error>Error</error>');
|
$output->writeln(' <error>Error</error>');
|
||||||
if ($output->isVerbose()) {
|
if ($output->isVerbose()) {
|
||||||
$this->getApplication()->renderException($e, $output);
|
$this->getApplication()->renderThrowable($e, $output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -140,13 +140,8 @@ class GenerateShortUrlCommand extends Command
|
||||||
sprintf('Generated short URL: <info>%s</info>', $shortUrl->toString($this->domainConfig)),
|
sprintf('Generated short URL: <info>%s</info>', $shortUrl->toString($this->domainConfig)),
|
||||||
]);
|
]);
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (InvalidUrlException $e) {
|
} catch (InvalidUrlException | NonUniqueSlugException $e) {
|
||||||
$io->error(sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl));
|
$io->error($e->getMessage());
|
||||||
return ExitCodes::EXIT_FAILURE;
|
|
||||||
} catch (NonUniqueSlugException $e) {
|
|
||||||
$io->error(
|
|
||||||
sprintf('Provided slug "%s" is already in use by another URL. Try with a different one.', $customSlug)
|
|
||||||
);
|
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,25 +4,22 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
use Zend\Stdlib\ArrayUtils;
|
|
||||||
|
|
||||||
use function array_map;
|
use function Functional\map;
|
||||||
use function Functional\select_keys;
|
use function Functional\select_keys;
|
||||||
|
|
||||||
class GetVisitsCommand extends Command
|
class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||||
{
|
{
|
||||||
public const NAME = 'short-url:visits';
|
public const NAME = 'short-url:visits';
|
||||||
private const ALIASES = ['shortcode:visits', 'short-code:visits'];
|
private const ALIASES = ['shortcode:visits', 'short-code:visits'];
|
||||||
|
@ -36,25 +33,23 @@ class GetVisitsCommand extends Command
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function doConfigure(): void
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->setName(self::NAME)
|
->setName(self::NAME)
|
||||||
->setAliases(self::ALIASES)
|
->setAliases(self::ALIASES)
|
||||||
->setDescription('Returns the detailed visits information for provided short code')
|
->setDescription('Returns the detailed visits information for provided short code')
|
||||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get')
|
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get');
|
||||||
->addOption(
|
}
|
||||||
'startDate',
|
|
||||||
's',
|
protected function getStartDateDesc(): string
|
||||||
InputOption::VALUE_OPTIONAL,
|
{
|
||||||
'Allows to filter visits, returning only those older than start date'
|
return 'Allows to filter visits, returning only those older than start date';
|
||||||
)
|
}
|
||||||
->addOption(
|
|
||||||
'endDate',
|
protected function getEndDateDesc(): string
|
||||||
'e',
|
{
|
||||||
InputOption::VALUE_OPTIONAL,
|
return 'Allows to filter visits, returning only those newer than end date';
|
||||||
'Allows to filter visits, returning only those newer than end date'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||||
|
@ -74,24 +69,18 @@ class GetVisitsCommand extends Command
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
{
|
{
|
||||||
$shortCode = $input->getArgument('shortCode');
|
$shortCode = $input->getArgument('shortCode');
|
||||||
$startDate = $this->getDateOption($input, 'startDate');
|
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||||
$endDate = $this->getDateOption($input, 'endDate');
|
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||||
|
|
||||||
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate)));
|
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate)));
|
||||||
$visits = ArrayUtils::iteratorToArray($paginator->getCurrentItems());
|
|
||||||
|
|
||||||
$rows = array_map(function (Visit $visit) {
|
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
|
||||||
$rowData = $visit->jsonSerialize();
|
$rowData = $visit->jsonSerialize();
|
||||||
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
|
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
|
||||||
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
||||||
}, $visits);
|
});
|
||||||
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
||||||
|
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getDateOption(InputInterface $input, $key)
|
|
||||||
{
|
|
||||||
$value = $input->getOption($key);
|
|
||||||
return ! empty($value) ? Chronos::parse($value) : $value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,14 +4,15 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
|
use Cake\Chronos\Chronos;
|
||||||
|
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||||
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||||
use Symfony\Component\Console\Command\Command;
|
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
@ -26,7 +27,7 @@ use function explode;
|
||||||
use function implode;
|
use function implode;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class ListShortUrlsCommand extends Command
|
class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||||
{
|
{
|
||||||
use PaginatorUtilsTrait;
|
use PaginatorUtilsTrait;
|
||||||
|
|
||||||
|
@ -43,17 +44,17 @@ class ListShortUrlsCommand extends Command
|
||||||
|
|
||||||
/** @var ShortUrlServiceInterface */
|
/** @var ShortUrlServiceInterface */
|
||||||
private $shortUrlService;
|
private $shortUrlService;
|
||||||
/** @var array */
|
/** @var ShortUrlDataTransformer */
|
||||||
private $domainConfig;
|
private $transformer;
|
||||||
|
|
||||||
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
|
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->shortUrlService = $shortUrlService;
|
$this->shortUrlService = $shortUrlService;
|
||||||
$this->domainConfig = $domainConfig;
|
$this->transformer = new ShortUrlDataTransformer($domainConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function configure(): void
|
protected function doConfigure(): void
|
||||||
{
|
{
|
||||||
$this
|
$this
|
||||||
->setName(self::NAME)
|
->setName(self::NAME)
|
||||||
|
@ -68,7 +69,7 @@ class ListShortUrlsCommand extends Command
|
||||||
)
|
)
|
||||||
->addOption(
|
->addOption(
|
||||||
'searchTerm',
|
'searchTerm',
|
||||||
's',
|
'st',
|
||||||
InputOption::VALUE_REQUIRED,
|
InputOption::VALUE_REQUIRED,
|
||||||
'A query used to filter results by searching for it on the longUrl and shortCode fields'
|
'A query used to filter results by searching for it on the longUrl and shortCode fields'
|
||||||
)
|
)
|
||||||
|
@ -87,18 +88,31 @@ class ListShortUrlsCommand extends Command
|
||||||
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not');
|
->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function getStartDateDesc(): string
|
||||||
|
{
|
||||||
|
return 'Allows to filter short URLs, returning only those created after "startDate"';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getEndDateDesc(): string
|
||||||
|
{
|
||||||
|
return 'Allows to filter short URLs, returning only those created before "endDate"';
|
||||||
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
|
||||||
$page = (int) $input->getOption('page');
|
$page = (int) $input->getOption('page');
|
||||||
$searchTerm = $input->getOption('searchTerm');
|
$searchTerm = $input->getOption('searchTerm');
|
||||||
$tags = $input->getOption('tags');
|
$tags = $input->getOption('tags');
|
||||||
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
$tags = ! empty($tags) ? explode(',', $tags) : [];
|
||||||
$showTags = (bool) $input->getOption('showTags');
|
$showTags = (bool) $input->getOption('showTags');
|
||||||
$transformer = new ShortUrlDataTransformer($this->domainConfig);
|
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||||
|
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||||
|
$orderBy = $this->processOrderBy($input);
|
||||||
|
|
||||||
do {
|
do {
|
||||||
$result = $this->renderPage($input, $output, $page, $searchTerm, $tags, $showTags, $transformer);
|
$result = $this->renderPage($output, $page, $searchTerm, $tags, $showTags, $startDate, $endDate, $orderBy);
|
||||||
$page++;
|
$page++;
|
||||||
|
|
||||||
$continue = $this->isLastPage($result)
|
$continue = $this->isLastPage($result)
|
||||||
|
@ -108,19 +122,27 @@ class ListShortUrlsCommand extends Command
|
||||||
|
|
||||||
$io->newLine();
|
$io->newLine();
|
||||||
$io->success('Short URLs properly listed');
|
$io->success('Short URLs properly listed');
|
||||||
|
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderPage(
|
private function renderPage(
|
||||||
InputInterface $input,
|
|
||||||
OutputInterface $output,
|
OutputInterface $output,
|
||||||
int $page,
|
int $page,
|
||||||
?string $searchTerm,
|
?string $searchTerm,
|
||||||
array $tags,
|
array $tags,
|
||||||
bool $showTags,
|
bool $showTags,
|
||||||
DataTransformerInterface $transformer
|
?Chronos $startDate,
|
||||||
|
?Chronos $endDate,
|
||||||
|
$orderBy
|
||||||
): Paginator {
|
): Paginator {
|
||||||
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
|
$result = $this->shortUrlService->listShortUrls(
|
||||||
|
$page,
|
||||||
|
$searchTerm,
|
||||||
|
$tags,
|
||||||
|
$orderBy,
|
||||||
|
new DateRange($startDate, $endDate)
|
||||||
|
);
|
||||||
|
|
||||||
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
|
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
|
||||||
if ($showTags) {
|
if ($showTags) {
|
||||||
|
@ -129,7 +151,7 @@ class ListShortUrlsCommand extends Command
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($result as $row) {
|
foreach ($result as $row) {
|
||||||
$shortUrl = $transformer->transform($row);
|
$shortUrl = $this->transformer->transform($row);
|
||||||
if ($showTags) {
|
if ($showTags) {
|
||||||
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
|
$shortUrl['tags'] = implode(', ', $shortUrl['tags']);
|
||||||
} else {
|
} else {
|
||||||
|
@ -143,9 +165,13 @@ class ListShortUrlsCommand extends Command
|
||||||
$result,
|
$result,
|
||||||
'Page %s of %s'
|
'Page %s of %s'
|
||||||
));
|
));
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array|string|null
|
||||||
|
*/
|
||||||
private function processOrderBy(InputInterface $input)
|
private function processOrderBy(InputInterface $input)
|
||||||
{
|
{
|
||||||
$orderBy = $input->getOption('orderBy');
|
$orderBy = $input->getOption('orderBy');
|
||||||
|
|
|
@ -5,8 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
@ -65,11 +64,8 @@ class ResolveUrlCommand extends Command
|
||||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||||
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (InvalidShortCodeException $e) {
|
} catch (ShortUrlNotFoundException $e) {
|
||||||
$io->error(sprintf('Provided short code "%s" has an invalid format.', $shortCode));
|
$io->error($e->getMessage());
|
||||||
return ExitCodes::EXIT_FAILURE;
|
|
||||||
} catch (EntityDoesNotExistException $e) {
|
|
||||||
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
|
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||||
|
|
||||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
|
||||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||||
|
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
@ -14,8 +14,6 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
use function sprintf;
|
|
||||||
|
|
||||||
class RenameTagCommand extends Command
|
class RenameTagCommand extends Command
|
||||||
{
|
{
|
||||||
public const NAME = 'tag:rename';
|
public const NAME = 'tag:rename';
|
||||||
|
@ -48,13 +46,8 @@ class RenameTagCommand extends Command
|
||||||
$this->tagService->renameTag($oldName, $newName);
|
$this->tagService->renameTag($oldName, $newName);
|
||||||
$io->success('Tag properly renamed.');
|
$io->success('Tag properly renamed.');
|
||||||
return ExitCodes::EXIT_SUCCESS;
|
return ExitCodes::EXIT_SUCCESS;
|
||||||
} catch (EntityDoesNotExistException $e) {
|
} catch (TagNotFoundException | TagConflictException $e) {
|
||||||
$io->error(sprintf('A tag with name "%s" was not found', $oldName));
|
$io->error($e->getMessage());
|
||||||
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;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,16 +8,16 @@ use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
abstract class AbstractLockedCommand extends Command
|
abstract class AbstractLockedCommand extends Command
|
||||||
{
|
{
|
||||||
/** @var Locker */
|
/** @var LockFactory */
|
||||||
private $locker;
|
private $locker;
|
||||||
|
|
||||||
public function __construct(Locker $locker)
|
public function __construct(LockFactory $locker)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->locker = $locker;
|
$this->locker = $locker;
|
||||||
|
|
54
module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php
Normal file
54
module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\CLI\Command\Util;
|
||||||
|
|
||||||
|
use Cake\Chronos\Chronos;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
abstract class AbstractWithDateRangeCommand extends Command
|
||||||
|
{
|
||||||
|
final protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->doConfigure();
|
||||||
|
$this
|
||||||
|
->addOption('startDate', 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc())
|
||||||
|
->addOption('endDate', 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
|
||||||
|
{
|
||||||
|
$value = $input->getOption($key);
|
||||||
|
if (empty($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Chronos::parse($value);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
|
||||||
|
$key,
|
||||||
|
$value
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($output->isVeryVerbose()) {
|
||||||
|
$this->getApplication()->renderThrowable($e, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract protected function doConfigure(): void;
|
||||||
|
|
||||||
|
abstract protected function getStartDateDesc(): string;
|
||||||
|
abstract protected function getEndDateDesc(): string;
|
||||||
|
}
|
|
@ -22,7 +22,7 @@ use Symfony\Component\Console\Helper\ProgressBar;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
@ -47,7 +47,7 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
||||||
public function __construct(
|
public function __construct(
|
||||||
VisitServiceInterface $visitService,
|
VisitServiceInterface $visitService,
|
||||||
IpLocationResolverInterface $ipLocationResolver,
|
IpLocationResolverInterface $ipLocationResolver,
|
||||||
Locker $locker,
|
LockFactory $locker,
|
||||||
GeolocationDbUpdaterInterface $dbUpdater
|
GeolocationDbUpdaterInterface $dbUpdater
|
||||||
) {
|
) {
|
||||||
parent::__construct($locker);
|
parent::__construct($locker);
|
||||||
|
@ -87,7 +87,7 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$this->io->error($e->getMessage());
|
$this->io->error($e->getMessage());
|
||||||
if ($e instanceof Exception && $this->io->isVerbose()) {
|
if ($e instanceof Exception && $this->io->isVerbose()) {
|
||||||
$this->getApplication()->renderException($e, $this->io);
|
$this->getApplication()->renderThrowable($e, $this->io);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
|
@ -116,7 +116,7 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
||||||
} catch (WrongIpException $e) {
|
} catch (WrongIpException $e) {
|
||||||
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
|
$this->io->writeln(' [<fg=red>An error occurred while locating IP. Skipped</>]');
|
||||||
if ($this->io->isVerbose()) {
|
if ($this->io->isVerbose()) {
|
||||||
$this->getApplication()->renderException($e, $this->io);
|
$this->getApplication()->renderThrowable($e, $this->io);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw IpCannotBeLocatedException::forError($e);
|
throw IpCannotBeLocatedException::forError($e);
|
||||||
|
|
|
@ -84,7 +84,7 @@ class UpdateDbCommand extends Command
|
||||||
|
|
||||||
$io->error($baseErrorMsg);
|
$io->error($baseErrorMsg);
|
||||||
if ($io->isVerbose()) {
|
if ($io->isVerbose()) {
|
||||||
$this->getApplication()->renderException($e, $io);
|
$this->getApplication()->renderThrowable($e, $io);
|
||||||
}
|
}
|
||||||
return ExitCodes::EXIT_FAILURE;
|
return ExitCodes::EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,20 +12,16 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
|
||||||
/** @var bool */
|
/** @var bool */
|
||||||
private $olderDbExists;
|
private $olderDbExists;
|
||||||
|
|
||||||
public function __construct(bool $olderDbExists, string $message = '', int $code = 0, ?Throwable $previous = null)
|
|
||||||
{
|
|
||||||
$this->olderDbExists = $olderDbExists;
|
|
||||||
parent::__construct($message, $code, $previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function create(bool $olderDbExists, ?Throwable $prev = null): self
|
public static function create(bool $olderDbExists, ?Throwable $prev = null): self
|
||||||
{
|
{
|
||||||
return new self(
|
$e = new self(
|
||||||
$olderDbExists,
|
|
||||||
'An error occurred while updating geolocation database, and an older version could not be found',
|
'An error occurred while updating geolocation database, and an older version could not be found',
|
||||||
0,
|
0,
|
||||||
$prev
|
$prev
|
||||||
);
|
);
|
||||||
|
$e->olderDbExists = $olderDbExists;
|
||||||
|
|
||||||
|
return $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function olderDbExists(): bool
|
public function olderDbExists(): bool
|
||||||
|
|
|
@ -9,8 +9,7 @@ use GeoIp2\Database\Reader;
|
||||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
|
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
|
||||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||||
{
|
{
|
||||||
|
@ -20,10 +19,10 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||||
private $dbUpdater;
|
private $dbUpdater;
|
||||||
/** @var Reader */
|
/** @var Reader */
|
||||||
private $geoLiteDbReader;
|
private $geoLiteDbReader;
|
||||||
/** @var Locker */
|
/** @var LockFactory */
|
||||||
private $locker;
|
private $locker;
|
||||||
|
|
||||||
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, Locker $locker)
|
public function __construct(DbUpdaterInterface $dbUpdater, Reader $geoLiteDbReader, LockFactory $locker)
|
||||||
{
|
{
|
||||||
$this->dbUpdater = $dbUpdater;
|
$this->dbUpdater = $dbUpdater;
|
||||||
$this->geoLiteDbReader = $geoLiteDbReader;
|
$this->geoLiteDbReader = $geoLiteDbReader;
|
||||||
|
@ -40,8 +39,6 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->downloadIfNeeded($mustBeUpdated, $handleProgress);
|
$this->downloadIfNeeded($mustBeUpdated, $handleProgress);
|
||||||
} catch (Throwable $e) {
|
|
||||||
throw $e;
|
|
||||||
} finally {
|
} finally {
|
||||||
$lock->release();
|
$lock->release();
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ class DisableKeyCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function providedApiKeyIsDisabled()
|
public function providedApiKeyIsDisabled(): void
|
||||||
{
|
{
|
||||||
$apiKey = 'abcd1234';
|
$apiKey = 'abcd1234';
|
||||||
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
|
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
|
||||||
|
@ -43,17 +43,18 @@ class DisableKeyCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function errorIsReturnedIfServiceThrowsException()
|
public function errorIsReturnedIfServiceThrowsException(): void
|
||||||
{
|
{
|
||||||
$apiKey = 'abcd1234';
|
$apiKey = 'abcd1234';
|
||||||
$disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class);
|
$expectedMessage = 'API key "abcd1234" does not exist.';
|
||||||
|
$disable = $this->apiKeyService->disable($apiKey)->willThrow(new InvalidArgumentException($expectedMessage));
|
||||||
|
|
||||||
$this->commandTester->execute([
|
$this->commandTester->execute([
|
||||||
'apiKey' => $apiKey,
|
'apiKey' => $apiKey,
|
||||||
]);
|
]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertStringContainsString('API key "abcd1234" does not exist.', $output);
|
$this->assertStringContainsString($expectedMessage, $output);
|
||||||
$disable->shouldHaveBeenCalledOnce();
|
$disable->shouldHaveBeenCalledOnce();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ use Symfony\Component\Console\Application;
|
||||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Lock\LockInterface;
|
use Symfony\Component\Lock\LockInterface;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ class CreateDatabaseCommandTest extends TestCase
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$locker = $this->prophesize(Locker::class);
|
$locker = $this->prophesize(LockFactory::class);
|
||||||
$lock = $this->prophesize(LockInterface::class);
|
$lock = $this->prophesize(LockInterface::class);
|
||||||
$lock->acquire(Argument::any())->willReturn(true);
|
$lock->acquire(Argument::any())->willReturn(true);
|
||||||
$lock->release()->will(function () {
|
$lock->release()->will(function () {
|
||||||
|
|
|
@ -12,7 +12,7 @@ use Symfony\Component\Console\Application;
|
||||||
use Symfony\Component\Console\Helper\ProcessHelper;
|
use Symfony\Component\Console\Helper\ProcessHelper;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
use Symfony\Component\Lock\Factory as Locker;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
use Symfony\Component\Lock\LockInterface;
|
use Symfony\Component\Lock\LockInterface;
|
||||||
use Symfony\Component\Process\PhpExecutableFinder;
|
use Symfony\Component\Process\PhpExecutableFinder;
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ class MigrateDatabaseCommandTest extends TestCase
|
||||||
|
|
||||||
public function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
$locker = $this->prophesize(Locker::class);
|
$locker = $this->prophesize(LockFactory::class);
|
||||||
$lock = $this->prophesize(LockInterface::class);
|
$lock = $this->prophesize(LockInterface::class);
|
||||||
$lock->acquire(Argument::any())->willReturn(true);
|
$lock->acquire(Argument::any())->willReturn(true);
|
||||||
$lock->release()->will(function () {
|
$lock->release()->will(function () {
|
||||||
|
|
|
@ -58,13 +58,13 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
||||||
Exception\InvalidShortCodeException::class
|
Exception\ShortUrlNotFoundException::fromNotFoundShortCode($shortCode)
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertStringContainsString(sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
|
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
|
||||||
$deleteByShortCode->shouldHaveBeenCalledOnce();
|
$deleteByShortCode->shouldHaveBeenCalledOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,11 +79,11 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||||
): void {
|
): void {
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
|
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
|
||||||
function (array $args) {
|
function (array $args) use ($shortCode) {
|
||||||
$ignoreThreshold = array_pop($args);
|
$ignoreThreshold = array_pop($args);
|
||||||
|
|
||||||
if (!$ignoreThreshold) {
|
if (!$ignoreThreshold) {
|
||||||
throw new Exception\DeleteShortUrlException(10);
|
throw Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -93,7 +93,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertStringContainsString(sprintf(
|
$this->assertStringContainsString(sprintf(
|
||||||
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
|
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
|
||||||
$shortCode
|
$shortCode
|
||||||
), $output);
|
), $output);
|
||||||
$this->assertStringContainsString($expectedMessage, $output);
|
$this->assertStringContainsString($expectedMessage, $output);
|
||||||
|
@ -112,7 +112,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
||||||
new Exception\DeleteShortUrlException(10)
|
Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode)
|
||||||
);
|
);
|
||||||
$this->commandTester->setInputs(['no']);
|
$this->commandTester->setInputs(['no']);
|
||||||
|
|
||||||
|
@ -120,7 +120,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertStringContainsString(sprintf(
|
$this->assertStringContainsString(sprintf(
|
||||||
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
|
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
|
||||||
$shortCode
|
$shortCode
|
||||||
), $output);
|
), $output);
|
||||||
$this->assertStringContainsString('Short URL was not deleted.', $output);
|
$this->assertStringContainsString('Short URL was not deleted.', $output);
|
||||||
|
|
|
@ -59,21 +59,22 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||||
/** @test */
|
/** @test */
|
||||||
public function exceptionWhileParsingLongUrlOutputsError(): void
|
public function exceptionWhileParsingLongUrlOutputsError(): void
|
||||||
{
|
{
|
||||||
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
|
$url = 'http://domain.com/invalid';
|
||||||
|
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
|
||||||
->shouldBeCalledOnce();
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid']);
|
$this->commandTester->execute(['longUrl' => $url]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
|
||||||
$this->assertStringContainsString('Provided URL "http://domain.com/invalid" is invalid.', $output);
|
$this->assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function providingNonUniqueSlugOutputsError(): void
|
public function providingNonUniqueSlugOutputsError(): void
|
||||||
{
|
{
|
||||||
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(
|
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(
|
||||||
NonUniqueSlugException::class
|
NonUniqueSlugException::fromSlug('my-slug')
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
|
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
|
||||||
|
|
|
@ -22,6 +22,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||||
use Zend\Paginator\Adapter\ArrayAdapter;
|
use Zend\Paginator\Adapter\ArrayAdapter;
|
||||||
use Zend\Paginator\Paginator;
|
use Zend\Paginator\Paginator;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
class GetVisitsCommandTest extends TestCase
|
class GetVisitsCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
/** @var CommandTester */
|
/** @var CommandTester */
|
||||||
|
@ -39,7 +41,7 @@ class GetVisitsCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function noDateFlagsTriesToListWithoutDateRange()
|
public function noDateFlagsTriesToListWithoutDateRange(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn(
|
$this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn(
|
||||||
|
@ -50,7 +52,7 @@ class GetVisitsCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function providingDateFlagsTheListGetsFiltered()
|
public function providingDateFlagsTheListGetsFiltered(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$startDate = '2016-01-01';
|
$startDate = '2016-01-01';
|
||||||
|
@ -69,6 +71,27 @@ class GetVisitsCommandTest extends TestCase
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function providingInvalidDatesPrintsWarning(): void
|
||||||
|
{
|
||||||
|
$shortCode = 'abc123';
|
||||||
|
$startDate = 'foo';
|
||||||
|
$info = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange()))
|
||||||
|
->willReturn(new Paginator(new ArrayAdapter([])));
|
||||||
|
|
||||||
|
$this->commandTester->execute([
|
||||||
|
'shortCode' => $shortCode,
|
||||||
|
'--startDate' => $startDate,
|
||||||
|
]);
|
||||||
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
|
$info->shouldHaveBeenCalledOnce();
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate),
|
||||||
|
$output
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function outputIsProperlyGenerated(): void
|
public function outputIsProperlyGenerated(): void
|
||||||
{
|
{
|
||||||
|
|
|
@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
|
||||||
|
|
||||||
|
use Cake\Chronos\Chronos;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\Argument;
|
use Prophecy\Argument;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||||
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
|
@ -15,6 +17,8 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||||
use Zend\Paginator\Adapter\ArrayAdapter;
|
use Zend\Paginator\Adapter\ArrayAdapter;
|
||||||
use Zend\Paginator\Paginator;
|
use Zend\Paginator\Paginator;
|
||||||
|
|
||||||
|
use function explode;
|
||||||
|
|
||||||
class ListShortUrlsCommandTest extends TestCase
|
class ListShortUrlsCommandTest extends TestCase
|
||||||
{
|
{
|
||||||
/** @var CommandTester */
|
/** @var CommandTester */
|
||||||
|
@ -32,17 +36,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function noInputCallsListJustOnce()
|
public function loadingMorePagesCallsListMoreTimes(): void
|
||||||
{
|
|
||||||
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
|
|
||||||
->shouldBeCalledOnce();
|
|
||||||
|
|
||||||
$this->commandTester->setInputs(['n']);
|
|
||||||
$this->commandTester->execute([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @test */
|
|
||||||
public function loadingMorePagesCallsListMoreTimes()
|
|
||||||
{
|
{
|
||||||
// The paginator will return more than one page
|
// The paginator will return more than one page
|
||||||
$data = [];
|
$data = [];
|
||||||
|
@ -64,7 +58,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function havingMorePagesButAnsweringNoCallsListJustOnce()
|
public function havingMorePagesButAnsweringNoCallsListJustOnce(): void
|
||||||
{
|
{
|
||||||
// The paginator will return more than one page
|
// The paginator will return more than one page
|
||||||
$data = [];
|
$data = [];
|
||||||
|
@ -72,8 +66,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||||
$data[] = new ShortUrl('url_' . $i);
|
$data[] = new ShortUrl('url_' . $i);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter($data)))
|
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
|
||||||
->shouldBeCalledOnce();
|
->willReturn(new Paginator(new ArrayAdapter($data)))
|
||||||
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->setInputs(['n']);
|
$this->commandTester->setInputs(['n']);
|
||||||
$this->commandTester->execute([]);
|
$this->commandTester->execute([]);
|
||||||
|
@ -89,25 +84,105 @@ class ListShortUrlsCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function passingPageWillMakeListStartOnThatPage()
|
public function passingPageWillMakeListStartOnThatPage(): void
|
||||||
{
|
{
|
||||||
$page = 5;
|
$page = 5;
|
||||||
$this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
|
$this->shortUrlService->listShortUrls($page, null, [], null, new DateRange())
|
||||||
->shouldBeCalledOnce();
|
->willReturn(new Paginator(new ArrayAdapter()))
|
||||||
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->setInputs(['y']);
|
$this->commandTester->setInputs(['y']);
|
||||||
$this->commandTester->execute(['--page' => $page]);
|
$this->commandTester->execute(['--page' => $page]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
|
public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
|
||||||
{
|
{
|
||||||
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
|
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
|
||||||
->shouldBeCalledOnce();
|
->willReturn(new Paginator(new ArrayAdapter()))
|
||||||
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->setInputs(['y']);
|
$this->commandTester->setInputs(['y']);
|
||||||
$this->commandTester->execute(['--showTags' => true]);
|
$this->commandTester->execute(['--showTags' => true]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
$this->assertStringContainsString('Tags', $output);
|
$this->assertStringContainsString('Tags', $output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
* @dataProvider provideArgs
|
||||||
|
*/
|
||||||
|
public function serviceIsInvokedWithProvidedArgs(
|
||||||
|
array $commandArgs,
|
||||||
|
?int $page,
|
||||||
|
?string $searchTerm,
|
||||||
|
array $tags,
|
||||||
|
?DateRange $dateRange
|
||||||
|
): void {
|
||||||
|
$listShortUrls = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, null, $dateRange)
|
||||||
|
->willReturn(new Paginator(new ArrayAdapter()));
|
||||||
|
|
||||||
|
$this->commandTester->setInputs(['n']);
|
||||||
|
$this->commandTester->execute($commandArgs);
|
||||||
|
|
||||||
|
$listShortUrls->shouldHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideArgs(): iterable
|
||||||
|
{
|
||||||
|
yield [[], 1, null, [], new DateRange()];
|
||||||
|
yield [['--page' => $page = 3], $page, null, [], new DateRange()];
|
||||||
|
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, [], new DateRange()];
|
||||||
|
yield [
|
||||||
|
['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||||
|
$page,
|
||||||
|
$searchTerm,
|
||||||
|
explode(',', $tags),
|
||||||
|
new DateRange(),
|
||||||
|
];
|
||||||
|
yield [
|
||||||
|
['--startDate' => $startDate = '2019-01-01'],
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
new DateRange(Chronos::parse($startDate)),
|
||||||
|
];
|
||||||
|
yield [
|
||||||
|
['--endDate' => $endDate = '2020-05-23'],
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
new DateRange(null, Chronos::parse($endDate)),
|
||||||
|
];
|
||||||
|
yield [
|
||||||
|
['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @test
|
||||||
|
* @dataProvider provideOrderBy
|
||||||
|
*/
|
||||||
|
public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void
|
||||||
|
{
|
||||||
|
$listShortUrls = $this->shortUrlService->listShortUrls(1, null, [], $expectedOrderBy, new DateRange())
|
||||||
|
->willReturn(new Paginator(new ArrayAdapter()));
|
||||||
|
|
||||||
|
$this->commandTester->setInputs(['n']);
|
||||||
|
$this->commandTester->execute($commandArgs);
|
||||||
|
|
||||||
|
$listShortUrls->shouldHaveBeenCalledOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provideOrderBy(): iterable
|
||||||
|
{
|
||||||
|
yield [[], null];
|
||||||
|
yield [['--orderBy' => 'foo'], 'foo'];
|
||||||
|
yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']];
|
||||||
|
yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,13 @@ use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
|
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
use const PHP_EOL;
|
use const PHP_EOL;
|
||||||
|
|
||||||
class ResolveUrlCommandTest extends TestCase
|
class ResolveUrlCommandTest extends TestCase
|
||||||
|
@ -51,23 +52,12 @@ class ResolveUrlCommandTest extends TestCase
|
||||||
public function incorrectShortCodeOutputsErrorMessage(): void
|
public function incorrectShortCodeOutputsErrorMessage(): void
|
||||||
{
|
{
|
||||||
$shortCode = 'abc123';
|
$shortCode = 'abc123';
|
||||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(EntityDoesNotExistException::class)
|
$this->urlShortener->shortCodeToUrl($shortCode, null)
|
||||||
->shouldBeCalledOnce();
|
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
|
||||||
|
->shouldBeCalledOnce();
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
$this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output);
|
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
|
||||||
}
|
|
||||||
|
|
||||||
/** @test */
|
|
||||||
public function wrongShortCodeFormatOutputsErrorMessage(): void
|
|
||||||
{
|
|
||||||
$shortCode = 'abc123';
|
|
||||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(new InvalidShortCodeException())
|
|
||||||
->shouldBeCalledOnce();
|
|
||||||
|
|
||||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
|
||||||
$output = $this->commandTester->getDisplay();
|
|
||||||
$this->assertStringContainsString('Provided short code "' . $shortCode . '" has an invalid format.', $output);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase;
|
||||||
use Prophecy\Prophecy\ObjectProphecy;
|
use Prophecy\Prophecy\ObjectProphecy;
|
||||||
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
||||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
use Symfony\Component\Console\Tester\CommandTester;
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
@ -34,11 +34,11 @@ class RenameTagCommandTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function errorIsPrintedIfExceptionIsThrown()
|
public function errorIsPrintedIfExceptionIsThrown(): void
|
||||||
{
|
{
|
||||||
$oldName = 'foo';
|
$oldName = 'foo';
|
||||||
$newName = 'bar';
|
$newName = 'bar';
|
||||||
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(EntityDoesNotExistException::class);
|
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo'));
|
||||||
|
|
||||||
$this->commandTester->execute([
|
$this->commandTester->execute([
|
||||||
'oldName' => $oldName,
|
'oldName' => $oldName,
|
||||||
|
@ -46,12 +46,12 @@ class RenameTagCommandTest extends TestCase
|
||||||
]);
|
]);
|
||||||
$output = $this->commandTester->getDisplay();
|
$output = $this->commandTester->getDisplay();
|
||||||
|
|
||||||
$this->assertStringContainsString('A tag with name "foo" was not found', $output);
|
$this->assertStringContainsString('Tag with name "foo" could not be found', $output);
|
||||||
$renameTag->shouldHaveBeenCalled();
|
$renameTag->shouldHaveBeenCalled();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
public function successIsPrintedIfNoErrorOccurs()
|
public function successIsPrintedIfNoErrorOccurs(): void
|
||||||
{
|
{
|
||||||
$oldName = 'foo';
|
$oldName = 'foo';
|
||||||
$newName = 'bar';
|
$newName = 'bar';
|
||||||
|
|
|
@ -48,7 +48,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||||
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||||
|
|
||||||
$this->locker = $this->prophesize(Lock\Factory::class);
|
$this->locker = $this->prophesize(Lock\LockFactory::class);
|
||||||
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
||||||
$this->lock->acquire(false)->willReturn(true);
|
$this->lock->acquire(false)->willReturn(true);
|
||||||
$this->lock->release()->will(function () {
|
$this->lock->release()->will(function () {
|
||||||
|
|
|
@ -12,47 +12,6 @@ use Throwable;
|
||||||
|
|
||||||
class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
class GeolocationDbUpdateFailedExceptionTest extends TestCase
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* @test
|
|
||||||
* @dataProvider provideOlderDbExists
|
|
||||||
*/
|
|
||||||
public function constructCreatesExceptionWithDefaultArgs(bool $olderDbExists): void
|
|
||||||
{
|
|
||||||
$e = new GeolocationDbUpdateFailedException($olderDbExists);
|
|
||||||
|
|
||||||
$this->assertEquals($olderDbExists, $e->olderDbExists());
|
|
||||||
$this->assertEquals('', $e->getMessage());
|
|
||||||
$this->assertEquals(0, $e->getCode());
|
|
||||||
$this->assertNull($e->getPrevious());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function provideOlderDbExists(): iterable
|
|
||||||
{
|
|
||||||
yield 'with older DB' => [true];
|
|
||||||
yield 'without older DB' => [false];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @test
|
|
||||||
* @dataProvider provideConstructorArgs
|
|
||||||
*/
|
|
||||||
public function constructCreatesException(bool $olderDbExists, string $message, int $code, ?Throwable $prev): void
|
|
||||||
{
|
|
||||||
$e = new GeolocationDbUpdateFailedException($olderDbExists, $message, $code, $prev);
|
|
||||||
|
|
||||||
$this->assertEquals($olderDbExists, $e->olderDbExists());
|
|
||||||
$this->assertEquals($message, $e->getMessage());
|
|
||||||
$this->assertEquals($code, $e->getCode());
|
|
||||||
$this->assertEquals($prev, $e->getPrevious());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function provideConstructorArgs(): iterable
|
|
||||||
{
|
|
||||||
yield [true, 'This is a nice error message', 99, new Exception('prev')];
|
|
||||||
yield [false, 'Another message', 0, new RuntimeException('prev')];
|
|
||||||
yield [true, 'An yet another message', -50, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @test
|
* @test
|
||||||
* @dataProvider provideCreateArgs
|
* @dataProvider provideCreateArgs
|
||||||
|
|
|
@ -38,7 +38,7 @@ class GeolocationDbUpdaterTest extends TestCase
|
||||||
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
|
$this->dbUpdater = $this->prophesize(DbUpdaterInterface::class);
|
||||||
$this->geoLiteDbReader = $this->prophesize(Reader::class);
|
$this->geoLiteDbReader = $this->prophesize(Reader::class);
|
||||||
|
|
||||||
$this->locker = $this->prophesize(Lock\Factory::class);
|
$this->locker = $this->prophesize(Lock\LockFactory::class);
|
||||||
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
$this->lock = $this->prophesize(Lock\LockInterface::class);
|
||||||
$this->lock->acquire(true)->willReturn(true);
|
$this->lock->acquire(true)->willReturn(true);
|
||||||
$this->lock->release()->will(function () {
|
$this->lock->release()->will(function () {
|
||||||
|
|
|
@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Core;
|
||||||
|
|
||||||
use Doctrine\Common\Cache\Cache;
|
use Doctrine\Common\Cache\Cache;
|
||||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ErrorHandler;
|
||||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||||
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
|
|
||||||
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator;
|
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator;
|
||||||
use Zend\Expressive\Router\RouterInterface;
|
use Zend\Expressive\Router\RouterInterface;
|
||||||
use Zend\Expressive\Template\TemplateRendererInterface;
|
use Zend\Expressive\Template\TemplateRendererInterface;
|
||||||
|
@ -17,7 +17,8 @@ return [
|
||||||
|
|
||||||
'dependencies' => [
|
'dependencies' => [
|
||||||
'factories' => [
|
'factories' => [
|
||||||
NotFoundHandler::class => ConfigAbstractFactory::class,
|
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
|
||||||
|
ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
Options\AppOptions::class => ConfigAbstractFactory::class,
|
Options\AppOptions::class => ConfigAbstractFactory::class,
|
||||||
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
|
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
|
||||||
|
@ -43,11 +44,8 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
ConfigAbstractFactory::class => [
|
ConfigAbstractFactory::class => [
|
||||||
NotFoundHandler::class => [
|
ErrorHandler\NotFoundRedirectHandler::class => [NotFoundRedirectOptions::class, 'config.router.base_path'],
|
||||||
TemplateRendererInterface::class,
|
ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
|
||||||
NotFoundRedirectOptions::class,
|
|
||||||
'config.router.base_path',
|
|
||||||
],
|
|
||||||
|
|
||||||
Options\AppOptions::class => ['config.app_options'],
|
Options\AppOptions::class => ['config.app_options'],
|
||||||
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
|
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
|
||||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core;
|
namespace Shlinkio\Shlink\Core;
|
||||||
|
|
||||||
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||||
|
@ -11,7 +12,11 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||||
return [
|
return [
|
||||||
|
|
||||||
'events' => [
|
'events' => [
|
||||||
'regular' => [],
|
'regular' => [
|
||||||
|
EventDispatcher\VisitLocated::class => [
|
||||||
|
EventDispatcher\NotifyVisitToWebHooks::class,
|
||||||
|
],
|
||||||
|
],
|
||||||
'async' => [
|
'async' => [
|
||||||
EventDispatcher\ShortUrlVisited::class => [
|
EventDispatcher\ShortUrlVisited::class => [
|
||||||
EventDispatcher\LocateShortUrlVisit::class,
|
EventDispatcher\LocateShortUrlVisit::class,
|
||||||
|
@ -22,6 +27,7 @@ return [
|
||||||
'dependencies' => [
|
'dependencies' => [
|
||||||
'factories' => [
|
'factories' => [
|
||||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||||
|
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -31,6 +37,15 @@ return [
|
||||||
'em',
|
'em',
|
||||||
'Logger_Shlink',
|
'Logger_Shlink',
|
||||||
GeolocationDbUpdater::class,
|
GeolocationDbUpdater::class,
|
||||||
|
EventDispatcherInterface::class,
|
||||||
|
],
|
||||||
|
EventDispatcher\NotifyVisitToWebHooks::class => [
|
||||||
|
'httpClient',
|
||||||
|
'em',
|
||||||
|
'Logger_Shlink',
|
||||||
|
'config.url_shortener.visits_webhooks',
|
||||||
|
'config.url_shortener.domain',
|
||||||
|
Options\AppOptions::class,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,7 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
|
||||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||||
|
@ -72,7 +71,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
|
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
|
||||||
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
|
} catch (ShortUrlNotFoundException $e) {
|
||||||
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
|
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
|
||||||
return $this->createErrorResp($request, $handler);
|
return $this->createErrorResp($request, $handler);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,7 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Shlinkio\Shlink\Common\Response\ResponseUtilsTrait;
|
use Shlinkio\Shlink\Common\Response\ResponseUtilsTrait;
|
||||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||||
use Shlinkio\Shlink\PreviewGenerator\Exception\PreviewGenerationException;
|
use Shlinkio\Shlink\PreviewGenerator\Exception\PreviewGenerationException;
|
||||||
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGeneratorInterface;
|
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGeneratorInterface;
|
||||||
|
@ -56,7 +55,7 @@ class PreviewAction implements MiddlewareInterface
|
||||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||||
$imagePath = $this->previewGenerator->generatePreview($url->getLongUrl());
|
$imagePath = $this->previewGenerator->generatePreview($url->getLongUrl());
|
||||||
return $this->generateImageResponse($imagePath);
|
return $this->generateImageResponse($imagePath);
|
||||||
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
|
} catch (ShortUrlNotFoundException | PreviewGenerationException $e) {
|
||||||
$this->logger->warning('An error occurred while generating preview image. {e}', ['e' => $e]);
|
$this->logger->warning('An error occurred while generating preview image. {e}', ['e' => $e]);
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,8 +12,7 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||||
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
|
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||||
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
|
|
||||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||||
use Zend\Expressive\Router\Exception\RuntimeException;
|
use Zend\Expressive\Router\Exception\RuntimeException;
|
||||||
use Zend\Expressive\Router\RouterInterface;
|
use Zend\Expressive\Router\RouterInterface;
|
||||||
|
@ -60,7 +59,7 @@ class QrCodeAction implements MiddlewareInterface
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
$this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||||
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
|
} catch (ShortUrlNotFoundException $e) {
|
||||||
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ class SimplifiedConfigParser
|
||||||
'base_path' => ['router', 'base_path'],
|
'base_path' => ['router', 'base_path'],
|
||||||
'web_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'worker_num'],
|
'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'],
|
'task_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
|
||||||
|
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
||||||
];
|
];
|
||||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||||
'delete_short_url_threshold' => [
|
'delete_short_url_threshold' => [
|
||||||
|
|
|
@ -61,6 +61,11 @@ class Visit extends AbstractEntity implements JsonSerializable
|
||||||
return ! empty($this->remoteAddr);
|
return ! empty($this->remoteAddr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getShortUrl(): ShortUrl
|
||||||
|
{
|
||||||
|
return $this->shortUrl;
|
||||||
|
}
|
||||||
|
|
||||||
public function getVisitLocation(): VisitLocationInterface
|
public function getVisitLocation(): VisitLocationInterface
|
||||||
{
|
{
|
||||||
return $this->visitLocation ?? new UnknownVisitLocation();
|
return $this->visitLocation ?? new UnknownVisitLocation();
|
||||||
|
|
|
@ -2,78 +2,40 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Response;
|
namespace Shlinkio\Shlink\Core\ErrorHandler;
|
||||||
|
|
||||||
use Fig\Http\Message\StatusCodeInterface;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Psr\Http\Message\UriInterface;
|
use Psr\Http\Message\UriInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
use Psr\Http\Server\RequestHandlerInterface;
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||||
use Zend\Diactoros\Response;
|
use Zend\Diactoros\Response;
|
||||||
use Zend\Expressive\Router\RouteResult;
|
use Zend\Expressive\Router\RouteResult;
|
||||||
use Zend\Expressive\Template\TemplateRendererInterface;
|
|
||||||
|
|
||||||
use function array_shift;
|
|
||||||
use function explode;
|
|
||||||
use function Functional\contains;
|
|
||||||
use function rtrim;
|
use function rtrim;
|
||||||
|
|
||||||
class NotFoundHandler implements RequestHandlerInterface
|
class NotFoundRedirectHandler implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404';
|
|
||||||
public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code';
|
|
||||||
|
|
||||||
/** @var TemplateRendererInterface */
|
|
||||||
private $renderer;
|
|
||||||
/** @var NotFoundRedirectOptions */
|
/** @var NotFoundRedirectOptions */
|
||||||
private $redirectOptions;
|
private $redirectOptions;
|
||||||
/** @var string */
|
/** @var string */
|
||||||
private $shlinkBasePath;
|
private $shlinkBasePath;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(NotFoundRedirectOptions $redirectOptions, string $shlinkBasePath)
|
||||||
TemplateRendererInterface $renderer,
|
{
|
||||||
NotFoundRedirectOptions $redirectOptions,
|
|
||||||
string $shlinkBasePath
|
|
||||||
) {
|
|
||||||
$this->renderer = $renderer;
|
|
||||||
$this->redirectOptions = $redirectOptions;
|
$this->redirectOptions = $redirectOptions;
|
||||||
$this->shlinkBasePath = $shlinkBasePath;
|
$this->shlinkBasePath = $shlinkBasePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
* Dispatch the next available middleware and return the response.
|
|
||||||
*
|
|
||||||
* @param ServerRequestInterface $request
|
|
||||||
*
|
|
||||||
* @return ResponseInterface
|
|
||||||
* @throws InvalidArgumentException
|
|
||||||
*/
|
|
||||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
|
||||||
{
|
{
|
||||||
/** @var RouteResult $routeResult */
|
/** @var RouteResult $routeResult */
|
||||||
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
||||||
$redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri());
|
$redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri());
|
||||||
if ($redirectResponse !== null) {
|
|
||||||
return $redirectResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
$accepts = explode(',', $request->getHeaderLine('Accept'));
|
return $redirectResponse ?? $handler->handle($request);
|
||||||
$accept = array_shift($accepts);
|
|
||||||
$status = StatusCodeInterface::STATUS_NOT_FOUND;
|
|
||||||
|
|
||||||
// If the first accepted type is json, return a json response
|
|
||||||
if (contains(['application/json', 'text/json', 'application/x-json'], $accept)) {
|
|
||||||
return new Response\JsonResponse([
|
|
||||||
'error' => 'NOT_FOUND',
|
|
||||||
'message' => 'Not found',
|
|
||||||
], $status);
|
|
||||||
}
|
|
||||||
|
|
||||||
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
|
|
||||||
return new Response\HtmlResponse($this->renderer->render($template), $status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface
|
private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface
|
46
module/Core/src/ErrorHandler/NotFoundTemplateHandler.php
Normal file
46
module/Core/src/ErrorHandler/NotFoundTemplateHandler.php
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\ErrorHandler;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
use Zend\Diactoros\Response;
|
||||||
|
use Zend\Expressive\Router\RouteResult;
|
||||||
|
use Zend\Expressive\Template\TemplateRendererInterface;
|
||||||
|
|
||||||
|
class NotFoundTemplateHandler implements RequestHandlerInterface
|
||||||
|
{
|
||||||
|
public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404';
|
||||||
|
public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code';
|
||||||
|
|
||||||
|
/** @var TemplateRendererInterface */
|
||||||
|
private $renderer;
|
||||||
|
|
||||||
|
public function __construct(TemplateRendererInterface $renderer)
|
||||||
|
{
|
||||||
|
$this->renderer = $renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the next available middleware and return the response.
|
||||||
|
*
|
||||||
|
* @param ServerRequestInterface $request
|
||||||
|
*
|
||||||
|
* @return ResponseInterface
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||||
|
{
|
||||||
|
/** @var RouteResult $routeResult */
|
||||||
|
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
|
||||||
|
$status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||||
|
|
||||||
|
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
|
||||||
|
return new Response\HtmlResponse($this->renderer->render($template), $status);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||||
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||||
|
@ -26,17 +27,21 @@ class LocateShortUrlVisit
|
||||||
private $logger;
|
private $logger;
|
||||||
/** @var GeolocationDbUpdaterInterface */
|
/** @var GeolocationDbUpdaterInterface */
|
||||||
private $dbUpdater;
|
private $dbUpdater;
|
||||||
|
/** @var EventDispatcherInterface */
|
||||||
|
private $eventDispatcher;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
IpLocationResolverInterface $ipLocationResolver,
|
IpLocationResolverInterface $ipLocationResolver,
|
||||||
EntityManagerInterface $em,
|
EntityManagerInterface $em,
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
GeolocationDbUpdaterInterface $dbUpdater
|
GeolocationDbUpdaterInterface $dbUpdater,
|
||||||
|
EventDispatcherInterface $eventDispatcher
|
||||||
) {
|
) {
|
||||||
$this->ipLocationResolver = $ipLocationResolver;
|
$this->ipLocationResolver = $ipLocationResolver;
|
||||||
$this->em = $em;
|
$this->em = $em;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
$this->dbUpdater = $dbUpdater;
|
$this->dbUpdater = $dbUpdater;
|
||||||
|
$this->eventDispatcher = $eventDispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
||||||
|
@ -46,10 +51,21 @@ class LocateShortUrlVisit
|
||||||
/** @var Visit|null $visit */
|
/** @var Visit|null $visit */
|
||||||
$visit = $this->em->find(Visit::class, $visitId);
|
$visit = $this->em->find(Visit::class, $visitId);
|
||||||
if ($visit === null) {
|
if ($visit === null) {
|
||||||
$this->logger->warning(sprintf('Tried to locate visit with id "%s", but it does not exist.', $visitId));
|
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
|
||||||
|
'visitId' => $visitId,
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
|
||||||
|
$this->locateVisit($visitId, $visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadOrUpdateGeoLiteDb(string $visitId): bool
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
|
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
|
||||||
$this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'));
|
$this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||||
|
@ -57,31 +73,32 @@ class LocateShortUrlVisit
|
||||||
} catch (GeolocationDbUpdateFailedException $e) {
|
} catch (GeolocationDbUpdateFailedException $e) {
|
||||||
if (! $e->olderDbExists()) {
|
if (! $e->olderDbExists()) {
|
||||||
$this->logger->error(
|
$this->logger->error(
|
||||||
sprintf(
|
'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}',
|
||||||
'GeoLite2 database download failed. It is not possible to locate visit with id %s. {e}',
|
['e' => $e, 'visitId' => $visitId]
|
||||||
$visitId
|
|
||||||
),
|
|
||||||
['e' => $e]
|
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]);
|
$this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function locateVisit(string $visitId, Visit $visit): void
|
||||||
|
{
|
||||||
try {
|
try {
|
||||||
$location = $visit->isLocatable()
|
$location = $visit->isLocatable()
|
||||||
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
|
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
|
||||||
: Location::emptyInstance();
|
: Location::emptyInstance();
|
||||||
|
|
||||||
|
$visit->locate(new VisitLocation($location));
|
||||||
|
$this->em->flush();
|
||||||
} catch (WrongIpException $e) {
|
} catch (WrongIpException $e) {
|
||||||
$this->logger->warning(
|
$this->logger->warning(
|
||||||
sprintf('Tried to locate visit with id "%s", but its address seems to be wrong. {e}', $visitId),
|
'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}',
|
||||||
['e' => $e]
|
['e' => $e, 'visitId' => $visitId]
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$visit->locate(new VisitLocation($location));
|
|
||||||
$this->em->flush($visit);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
113
module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php
Normal file
113
module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Fig\Http\Message\RequestMethodInterface;
|
||||||
|
use GuzzleHttp\ClientInterface;
|
||||||
|
use GuzzleHttp\Promise\Promise;
|
||||||
|
use GuzzleHttp\RequestOptions;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||||
|
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||||
|
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
use function Functional\map;
|
||||||
|
use function Functional\partial_left;
|
||||||
|
use function GuzzleHttp\Promise\settle;
|
||||||
|
|
||||||
|
class NotifyVisitToWebHooks
|
||||||
|
{
|
||||||
|
/** @var ClientInterface */
|
||||||
|
private $httpClient;
|
||||||
|
/** @var EntityManagerInterface */
|
||||||
|
private $em;
|
||||||
|
/** @var LoggerInterface */
|
||||||
|
private $logger;
|
||||||
|
/** @var array */
|
||||||
|
private $webhooks;
|
||||||
|
/** @var ShortUrlDataTransformer */
|
||||||
|
private $transformer;
|
||||||
|
/** @var AppOptions */
|
||||||
|
private $appOptions;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ClientInterface $httpClient,
|
||||||
|
EntityManagerInterface $em,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
array $webhooks,
|
||||||
|
array $domainConfig,
|
||||||
|
AppOptions $appOptions
|
||||||
|
) {
|
||||||
|
$this->httpClient = $httpClient;
|
||||||
|
$this->em = $em;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->webhooks = $webhooks;
|
||||||
|
$this->transformer = new ShortUrlDataTransformer($domainConfig);
|
||||||
|
$this->appOptions = $appOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(VisitLocated $shortUrlLocated): void
|
||||||
|
{
|
||||||
|
if (empty($this->webhooks)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visitId = $shortUrlLocated->visitId();
|
||||||
|
|
||||||
|
/** @var Visit|null $visit */
|
||||||
|
$visit = $this->em->find(Visit::class, $visitId);
|
||||||
|
if ($visit === null) {
|
||||||
|
$this->logger->warning('Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', [
|
||||||
|
'visitId' => $visitId,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestOptions = $this->buildRequestOptions($visit);
|
||||||
|
$requestPromises = $this->performRequests($requestOptions, $visitId);
|
||||||
|
|
||||||
|
// Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error.
|
||||||
|
settle($requestPromises)->wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildRequestOptions(Visit $visit): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RequestOptions::TIMEOUT => 10,
|
||||||
|
RequestOptions::HEADERS => [
|
||||||
|
'User-Agent' => (string) $this->appOptions,
|
||||||
|
],
|
||||||
|
RequestOptions::JSON => [
|
||||||
|
'shortUrl' => $this->transformer->transform($visit->getShortUrl(), false),
|
||||||
|
'visit' => $visit->jsonSerialize(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Promise[] $requestOptions
|
||||||
|
*/
|
||||||
|
private function performRequests(array $requestOptions, string $visitId): array
|
||||||
|
{
|
||||||
|
return map($this->webhooks, function (string $webhook) use ($requestOptions, $visitId) {
|
||||||
|
$promise = $this->httpClient->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions);
|
||||||
|
return $promise->otherwise(
|
||||||
|
partial_left(Closure::fromCallable([$this, 'logWebhookFailure']), $webhook, $visitId)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void
|
||||||
|
{
|
||||||
|
$this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [
|
||||||
|
'visitId' => $visitId,
|
||||||
|
'webhook' => $webhook,
|
||||||
|
'e' => $e,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
28
module/Core/src/EventDispatcher/VisitLocated.php
Normal file
28
module/Core/src/EventDispatcher/VisitLocated.php
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
final class VisitLocated implements JsonSerializable
|
||||||
|
{
|
||||||
|
/** @var string */
|
||||||
|
private $visitId;
|
||||||
|
|
||||||
|
public function __construct(string $visitId)
|
||||||
|
{
|
||||||
|
$this->visitId = $visitId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function visitId(): string
|
||||||
|
{
|
||||||
|
return $this->visitId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return ['visitId' => $this->visitId];
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,32 +4,41 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Exception;
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
use Throwable;
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
|
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
|
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class DeleteShortUrlException extends RuntimeException
|
class DeleteShortUrlException extends DomainException implements ProblemDetailsExceptionInterface
|
||||||
{
|
{
|
||||||
/** @var int */
|
use CommonProblemDetailsExceptionTrait;
|
||||||
private $visitsThreshold;
|
|
||||||
|
|
||||||
public function __construct(int $visitsThreshold, string $message = '', int $code = 0, ?Throwable $previous = null)
|
private const TITLE = 'Cannot delete short URL';
|
||||||
{
|
private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION
|
||||||
$this->visitsThreshold = $visitsThreshold;
|
|
||||||
parent::__construct($message, $code, $previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromVisitsThreshold(int $threshold, string $shortCode): self
|
public static function fromVisitsThreshold(int $threshold, string $shortCode): self
|
||||||
{
|
{
|
||||||
return new self($threshold, sprintf(
|
$e = new self(sprintf(
|
||||||
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
|
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
|
||||||
$shortCode,
|
$shortCode,
|
||||||
$threshold
|
$threshold
|
||||||
));
|
));
|
||||||
|
|
||||||
|
$e->detail = $e->getMessage();
|
||||||
|
$e->title = self::TITLE;
|
||||||
|
$e->type = self::TYPE;
|
||||||
|
$e->status = StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY;
|
||||||
|
$e->additional = [
|
||||||
|
'shortCode' => $shortCode,
|
||||||
|
'threshold' => $threshold,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getVisitsThreshold(): int
|
public function getVisitsThreshold(): int
|
||||||
{
|
{
|
||||||
return $this->visitsThreshold;
|
return $this->additional['threshold'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
11
module/Core/src/Exception/DomainException.php
Normal file
11
module/Core/src/Exception/DomainException.php
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use DomainException as SplDomainException;
|
||||||
|
|
||||||
|
class DomainException extends SplDomainException implements ExceptionInterface
|
||||||
|
{
|
||||||
|
}
|
|
@ -1,30 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Exception;
|
|
||||||
|
|
||||||
use function implode;
|
|
||||||
use function sprintf;
|
|
||||||
|
|
||||||
class EntityDoesNotExistException extends RuntimeException
|
|
||||||
{
|
|
||||||
public static function createFromEntityAndConditions($entityName, array $conditions)
|
|
||||||
{
|
|
||||||
return new self(sprintf(
|
|
||||||
'Entity of type %s with params [%s] does not exist',
|
|
||||||
$entityName,
|
|
||||||
static::serializeParams($conditions)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function serializeParams(array $params)
|
|
||||||
{
|
|
||||||
$result = [];
|
|
||||||
foreach ($params as $key => $value) {
|
|
||||||
$result[] = sprintf('"%s" => "%s"', $key, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(', ', $result);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Exception;
|
|
||||||
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
use function sprintf;
|
|
||||||
|
|
||||||
class InvalidShortCodeException extends RuntimeException
|
|
||||||
{
|
|
||||||
public static function fromCharset(string $shortCode, string $charSet, ?Throwable $previous = null): self
|
|
||||||
{
|
|
||||||
$code = $previous !== null ? $previous->getCode() : -1;
|
|
||||||
return new static(
|
|
||||||
sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
|
|
||||||
$code,
|
|
||||||
$previous
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromNotFoundShortCode(string $shortCode): self
|
|
||||||
{
|
|
||||||
return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,15 +4,31 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Exception;
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
|
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class InvalidUrlException extends RuntimeException
|
class InvalidUrlException extends DomainException implements ProblemDetailsExceptionInterface
|
||||||
{
|
{
|
||||||
|
use CommonProblemDetailsExceptionTrait;
|
||||||
|
|
||||||
|
private const TITLE = 'Invalid URL';
|
||||||
|
private const TYPE = 'INVALID_URL';
|
||||||
|
|
||||||
public static function fromUrl(string $url, ?Throwable $previous = null): self
|
public static function fromUrl(string $url, ?Throwable $previous = null): self
|
||||||
{
|
{
|
||||||
$code = $previous !== null ? $previous->getCode() : -1;
|
$status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||||
return new static(sprintf('Provided URL "%s" is not an existing and valid URL', $url), $code, $previous);
|
$e = new self(sprintf('Provided URL %s is invalid. Try with a different one.', $url), $status, $previous);
|
||||||
|
|
||||||
|
$e->detail = $e->getMessage();
|
||||||
|
$e->title = self::TITLE;
|
||||||
|
$e->type = self::TYPE;
|
||||||
|
$e->status = $status;
|
||||||
|
$e->additional = ['url' => $url];
|
||||||
|
|
||||||
|
return $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,17 +4,34 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Exception;
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
|
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
|
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class NonUniqueSlugException extends InvalidArgumentException
|
class NonUniqueSlugException extends InvalidArgumentException implements ProblemDetailsExceptionInterface
|
||||||
{
|
{
|
||||||
public static function fromSlug(string $slug, ?string $domain): self
|
use CommonProblemDetailsExceptionTrait;
|
||||||
|
|
||||||
|
private const TITLE = 'Invalid custom slug';
|
||||||
|
private const TYPE = 'INVALID_SLUG';
|
||||||
|
|
||||||
|
public static function fromSlug(string $slug, ?string $domain = null): self
|
||||||
{
|
{
|
||||||
$suffix = '';
|
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
|
||||||
|
$e = new self(sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix));
|
||||||
|
|
||||||
|
$e->detail = $e->getMessage();
|
||||||
|
$e->title = self::TITLE;
|
||||||
|
$e->type = self::TYPE;
|
||||||
|
$e->status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||||
|
$e->additional = ['customSlug' => $slug];
|
||||||
|
|
||||||
if ($domain !== null) {
|
if ($domain !== null) {
|
||||||
$suffix = sprintf(' for domain "%s"', $domain);
|
$e->additional['domain'] = $domain;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new self(sprintf('Provided slug "%s" is not unique%s.', $slug, $suffix));
|
return $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
37
module/Core/src/Exception/ShortUrlNotFoundException.php
Normal file
37
module/Core/src/Exception/ShortUrlNotFoundException.php
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
|
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
|
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
class ShortUrlNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
|
||||||
|
{
|
||||||
|
use CommonProblemDetailsExceptionTrait;
|
||||||
|
|
||||||
|
private const TITLE = 'Short URL not found';
|
||||||
|
private const TYPE = 'INVALID_SHORTCODE';
|
||||||
|
|
||||||
|
public static function fromNotFoundShortCode(string $shortCode, ?string $domain = null): self
|
||||||
|
{
|
||||||
|
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
|
||||||
|
$e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix));
|
||||||
|
|
||||||
|
$e->detail = $e->getMessage();
|
||||||
|
$e->title = self::TITLE;
|
||||||
|
$e->type = self::TYPE;
|
||||||
|
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||||
|
$e->additional = ['shortCode' => $shortCode];
|
||||||
|
|
||||||
|
if ($domain !== null) {
|
||||||
|
$e->additional['domain'] = $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $e;
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,12 +4,32 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Exception;
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
|
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
|
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class TagConflictException extends RuntimeException
|
class TagConflictException extends RuntimeException implements ProblemDetailsExceptionInterface
|
||||||
{
|
{
|
||||||
|
use CommonProblemDetailsExceptionTrait;
|
||||||
|
|
||||||
|
private const TITLE = 'Tag conflict';
|
||||||
|
private const TYPE = 'TAG_CONFLICT';
|
||||||
|
|
||||||
public static function fromExistingTag(string $oldName, string $newName): self
|
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));
|
$e = new self(sprintf('You cannot rename tag %s to %s, because it already exists', $oldName, $newName));
|
||||||
|
|
||||||
|
$e->detail = $e->getMessage();
|
||||||
|
$e->title = self::TITLE;
|
||||||
|
$e->type = self::TYPE;
|
||||||
|
$e->status = StatusCodeInterface::STATUS_CONFLICT;
|
||||||
|
$e->additional = [
|
||||||
|
'oldName' => $oldName,
|
||||||
|
'newName' => $newName,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
32
module/Core/src/Exception/TagNotFoundException.php
Normal file
32
module/Core/src/Exception/TagNotFoundException.php
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
|
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
|
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
class TagNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
|
||||||
|
{
|
||||||
|
use CommonProblemDetailsExceptionTrait;
|
||||||
|
|
||||||
|
private const TITLE = 'Tag not found';
|
||||||
|
private const TYPE = 'TAG_NOT_FOUND';
|
||||||
|
|
||||||
|
public static function fromTag(string $tag): self
|
||||||
|
{
|
||||||
|
$e = new self(sprintf('Tag with name "%s" could not be found', $tag));
|
||||||
|
|
||||||
|
$e->detail = $e->getMessage();
|
||||||
|
$e->title = self::TITLE;
|
||||||
|
$e->type = self::TYPE;
|
||||||
|
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||||
|
$e->additional = ['tag' => $tag];
|
||||||
|
|
||||||
|
return $e;
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,9 +4,13 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Exception;
|
namespace Shlinkio\Shlink\Core\Exception;
|
||||||
|
|
||||||
|
use Fig\Http\Message\StatusCodeInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use Zend\InputFilter\InputFilterInterface;
|
use Zend\InputFilter\InputFilterInterface;
|
||||||
|
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||||
|
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||||
|
|
||||||
|
use function array_keys;
|
||||||
use function Functional\reduce_left;
|
use function Functional\reduce_left;
|
||||||
use function is_array;
|
use function is_array;
|
||||||
use function print_r;
|
use function print_r;
|
||||||
|
@ -14,44 +18,59 @@ use function sprintf;
|
||||||
|
|
||||||
use const PHP_EOL;
|
use const PHP_EOL;
|
||||||
|
|
||||||
class ValidationException extends RuntimeException
|
class ValidationException extends InvalidArgumentException implements ProblemDetailsExceptionInterface
|
||||||
{
|
{
|
||||||
|
use CommonProblemDetailsExceptionTrait;
|
||||||
|
|
||||||
|
private const TITLE = 'Invalid data';
|
||||||
|
private const TYPE = 'INVALID_ARGUMENT';
|
||||||
|
|
||||||
/** @var array */
|
/** @var array */
|
||||||
private $invalidElements;
|
private $invalidElements;
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
string $message = '',
|
|
||||||
array $invalidElements = [],
|
|
||||||
int $code = 0,
|
|
||||||
?Throwable $previous = null
|
|
||||||
) {
|
|
||||||
$this->invalidElements = $invalidElements;
|
|
||||||
parent::__construct($message, $code, $previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
|
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
|
||||||
{
|
{
|
||||||
return static::fromArray($inputFilter->getMessages(), $prev);
|
return static::fromArray($inputFilter->getMessages(), $prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function fromArray(array $invalidData, ?Throwable $prev = null): self
|
public static function fromArray(array $invalidData, ?Throwable $prev = null): self
|
||||||
{
|
{
|
||||||
return new self(
|
$status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||||
sprintf(
|
$e = new self('Provided data is not valid', $status, $prev);
|
||||||
'Provided data is not valid. These are the messages:%s%s%s',
|
|
||||||
PHP_EOL,
|
$e->detail = $e->getMessage();
|
||||||
self::formMessagesToString($invalidData),
|
$e->title = self::TITLE;
|
||||||
PHP_EOL
|
$e->type = self::TYPE;
|
||||||
),
|
$e->status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||||
$invalidData,
|
$e->invalidElements = $invalidData;
|
||||||
-1,
|
$e->additional = ['invalidElements' => array_keys($invalidData)];
|
||||||
$prev
|
|
||||||
|
return $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInvalidElements(): array
|
||||||
|
{
|
||||||
|
return $this->invalidElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%s %s in %s:%s%s%sStack trace:%s%s',
|
||||||
|
__CLASS__,
|
||||||
|
$this->getMessage(),
|
||||||
|
$this->getFile(),
|
||||||
|
$this->getLine(),
|
||||||
|
$this->invalidElementsToString(),
|
||||||
|
PHP_EOL,
|
||||||
|
PHP_EOL,
|
||||||
|
$this->getTraceAsString()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function formMessagesToString(array $messages = []): string
|
private function invalidElementsToString(): string
|
||||||
{
|
{
|
||||||
return reduce_left($messages, function ($messageSet, $name, $_, string $acc) {
|
return reduce_left($this->getInvalidElements(), function ($messageSet, string $name, $_, string $acc) {
|
||||||
return $acc . sprintf(
|
return $acc . sprintf(
|
||||||
"\n '%s' => %s",
|
"\n '%s' => %s",
|
||||||
$name,
|
$name,
|
||||||
|
@ -59,9 +78,4 @@ class ValidationException extends RuntimeException
|
||||||
);
|
);
|
||||||
}, '');
|
}, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getInvalidElements(): array
|
|
||||||
{
|
|
||||||
return $this->invalidElements;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||||
|
|
||||||
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||||
use Zend\Paginator\Adapter\AdapterInterface;
|
use Zend\Paginator\Adapter\AdapterInterface;
|
||||||
|
|
||||||
|
@ -22,17 +23,21 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||||
private $orderBy;
|
private $orderBy;
|
||||||
/** @var array */
|
/** @var array */
|
||||||
private $tags;
|
private $tags;
|
||||||
|
/** @var DateRange|null */
|
||||||
|
private $dateRange;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ShortUrlRepositoryInterface $repository,
|
ShortUrlRepositoryInterface $repository,
|
||||||
$searchTerm = null,
|
$searchTerm = null,
|
||||||
array $tags = [],
|
array $tags = [],
|
||||||
$orderBy = null
|
$orderBy = null,
|
||||||
|
?DateRange $dateRange = null
|
||||||
) {
|
) {
|
||||||
$this->repository = $repository;
|
$this->repository = $repository;
|
||||||
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
|
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
|
||||||
$this->orderBy = $orderBy;
|
$this->orderBy = $orderBy;
|
||||||
$this->tags = $tags;
|
$this->tags = $tags;
|
||||||
|
$this->dateRange = $dateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,7 +54,8 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||||
$offset,
|
$offset,
|
||||||
$this->searchTerm,
|
$this->searchTerm,
|
||||||
$this->tags,
|
$this->tags,
|
||||||
$this->orderBy
|
$this->orderBy,
|
||||||
|
$this->dateRange
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,6 +70,6 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||||
*/
|
*/
|
||||||
public function count(): int
|
public function count(): int
|
||||||
{
|
{
|
||||||
return $this->repository->countList($this->searchTerm, $this->tags);
|
return $this->repository->countList($this->searchTerm, $this->tags, $this->dateRange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Repository;
|
||||||
use Cake\Chronos\Chronos;
|
use Cake\Chronos\Chronos;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
|
|
||||||
use function array_column;
|
use function array_column;
|
||||||
|
@ -27,9 +28,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||||
?int $offset = null,
|
?int $offset = null,
|
||||||
?string $searchTerm = null,
|
?string $searchTerm = null,
|
||||||
array $tags = [],
|
array $tags = [],
|
||||||
$orderBy = null
|
$orderBy = null,
|
||||||
|
?DateRange $dateRange = null
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createListQueryBuilder($searchTerm, $tags);
|
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
|
||||||
$qb->select('DISTINCT s');
|
$qb->select('DISTINCT s');
|
||||||
|
|
||||||
// Set limit and offset
|
// Set limit and offset
|
||||||
|
@ -52,15 +54,9 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||||
|
|
||||||
private function processOrderByForList(QueryBuilder $qb, $orderBy): array
|
private function processOrderByForList(QueryBuilder $qb, $orderBy): array
|
||||||
{
|
{
|
||||||
// Map public field names to column names
|
$isArray = is_array($orderBy);
|
||||||
$fieldNameMap = [
|
$fieldName = $isArray ? key($orderBy) : $orderBy;
|
||||||
'originalUrl' => 'longUrl',
|
$order = $isArray ? $orderBy[$fieldName] : 'ASC';
|
||||||
'longUrl' => 'longUrl',
|
|
||||||
'shortCode' => 'shortCode',
|
|
||||||
'dateCreated' => 'dateCreated',
|
|
||||||
];
|
|
||||||
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
|
|
||||||
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
|
|
||||||
|
|
||||||
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
|
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
|
||||||
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
|
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
|
||||||
|
@ -71,26 +67,45 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||||
return array_column($qb->getQuery()->getResult(), 0);
|
return array_column($qb->getQuery()->getResult(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map public field names to column names
|
||||||
|
$fieldNameMap = [
|
||||||
|
'originalUrl' => 'longUrl',
|
||||||
|
'longUrl' => 'longUrl',
|
||||||
|
'shortCode' => 'shortCode',
|
||||||
|
'dateCreated' => 'dateCreated',
|
||||||
|
];
|
||||||
if (array_key_exists($fieldName, $fieldNameMap)) {
|
if (array_key_exists($fieldName, $fieldNameMap)) {
|
||||||
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
|
$qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
|
||||||
}
|
}
|
||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countList(?string $searchTerm = null, array $tags = []): int
|
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int
|
||||||
{
|
{
|
||||||
$qb = $this->createListQueryBuilder($searchTerm, $tags);
|
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
|
||||||
$qb->select('COUNT(DISTINCT s)');
|
$qb->select('COUNT(DISTINCT s)');
|
||||||
|
|
||||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createListQueryBuilder(?string $searchTerm = null, array $tags = []): QueryBuilder
|
private function createListQueryBuilder(
|
||||||
{
|
?string $searchTerm = null,
|
||||||
|
array $tags = [],
|
||||||
|
?DateRange $dateRange = null
|
||||||
|
): QueryBuilder {
|
||||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
$qb->from(ShortUrl::class, 's');
|
$qb->from(ShortUrl::class, 's');
|
||||||
$qb->where('1=1');
|
$qb->where('1=1');
|
||||||
|
|
||||||
|
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||||
|
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
|
||||||
|
$qb->setParameter('startDate', $dateRange->getStartDate());
|
||||||
|
}
|
||||||
|
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
|
||||||
|
$qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate'));
|
||||||
|
$qb->setParameter('endDate', $dateRange->getEndDate());
|
||||||
|
}
|
||||||
|
|
||||||
// Apply search term to every searchable field if not empty
|
// Apply search term to every searchable field if not empty
|
||||||
if (! empty($searchTerm)) {
|
if (! empty($searchTerm)) {
|
||||||
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
|
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
|
||||||
|
@ -98,14 +113,12 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||||
$qb->leftJoin('s.tags', 't');
|
$qb->leftJoin('s.tags', 't');
|
||||||
}
|
}
|
||||||
|
|
||||||
$conditions = [
|
// Apply search conditions
|
||||||
|
$qb->andWhere($qb->expr()->orX(
|
||||||
$qb->expr()->like('s.longUrl', ':searchPattern'),
|
$qb->expr()->like('s.longUrl', ':searchPattern'),
|
||||||
$qb->expr()->like('s.shortCode', ':searchPattern'),
|
$qb->expr()->like('s.shortCode', ':searchPattern'),
|
||||||
$qb->expr()->like('t.name', ':searchPattern'),
|
$qb->expr()->like('t.name', ':searchPattern')
|
||||||
];
|
));
|
||||||
|
|
||||||
// Unpack and apply search conditions
|
|
||||||
$qb->andWhere($qb->expr()->orX(...$conditions));
|
|
||||||
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
|
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,14 +4,13 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\Repository;
|
namespace Shlinkio\Shlink\Core\Repository;
|
||||||
|
|
||||||
use Doctrine\Common\Persistence\ObjectRepository;
|
use Doctrine\Persistence\ObjectRepository;
|
||||||
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||||
|
|
||||||
interface ShortUrlRepositoryInterface extends ObjectRepository
|
interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Gets a list of elements using provided filtering data
|
|
||||||
*
|
|
||||||
* @param string|array|null $orderBy
|
* @param string|array|null $orderBy
|
||||||
*/
|
*/
|
||||||
public function findList(
|
public function findList(
|
||||||
|
@ -19,13 +18,11 @@ interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||||
?int $offset = null,
|
?int $offset = null,
|
||||||
?string $searchTerm = null,
|
?string $searchTerm = null,
|
||||||
array $tags = [],
|
array $tags = [],
|
||||||
$orderBy = null
|
$orderBy = null,
|
||||||
|
?DateRange $dateRange = null
|
||||||
): array;
|
): array;
|
||||||
|
|
||||||
/**
|
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int;
|
||||||
* Counts the number of elements in a list using provided filtering data
|
|
||||||
*/
|
|
||||||
public function countList(?string $searchTerm = null, array $tags = []): int;
|
|
||||||
|
|
||||||
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl;
|
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue