mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-04 06:13:12 +08:00
Compare commits
149 Commits
v2.0.5
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704958994d | ||
|
|
a6864bca7c | ||
|
|
15a8305209 | ||
|
|
469b70d708 | ||
|
|
4f988d223b | ||
|
|
e95abc4efb | ||
|
|
4917e53acd | ||
|
|
45db4c321a | ||
|
|
e6d914cfe1 | ||
|
|
85714c931d | ||
|
|
66a7f279c2 | ||
|
|
7c6827ea9f | ||
|
|
078c8ea011 | ||
|
|
655fd58a9d | ||
|
|
6ba6b951bf | ||
|
|
8e0e11f3b3 | ||
|
|
18b12ab1e6 | ||
|
|
3908f63b0d | ||
|
|
ca2c32fa8c | ||
|
|
a3a3ac1859 | ||
|
|
f5e0d0c2b1 | ||
|
|
ba0678946f | ||
|
|
934fa937b5 | ||
|
|
8d888cb43d | ||
|
|
7f888c49b4 | ||
|
|
e97dfbfdda | ||
|
|
b858d79b9e | ||
|
|
72d8edf4ff | ||
|
|
31db97228d | ||
|
|
2ffbf03cf8 | ||
|
|
85440c1c5f | ||
|
|
69962f1fe8 | ||
|
|
10cad33248 | ||
|
|
0c9deca3f8 | ||
|
|
e1cd4a6ee3 | ||
|
|
f915b97606 | ||
|
|
3ee5853b32 | ||
|
|
832a24e4c7 | ||
|
|
551368c30d | ||
|
|
9f24b8eb76 | ||
|
|
4c83ae2b22 | ||
|
|
28e0fb049b | ||
|
|
f79a369884 | ||
|
|
34c7b870a7 | ||
|
|
ec9f874bb9 | ||
|
|
1980d35691 | ||
|
|
ec8cbf82e5 | ||
|
|
2b1011de52 | ||
|
|
fa9ace83ad | ||
|
|
a9a53a9652 | ||
|
|
afca8b2a62 | ||
|
|
daeb293fb9 | ||
|
|
1ca50a4a8a | ||
|
|
c6602a81ab | ||
|
|
46da0e7824 | ||
|
|
e790a38cea | ||
|
|
11879ea377 | ||
|
|
7105add009 | ||
|
|
af61fdb52d | ||
|
|
2b4fc354db | ||
|
|
5b72001a8c | ||
|
|
7c79906ac4 | ||
|
|
e30a724529 | ||
|
|
73f97ea874 | ||
|
|
09c155b7d3 | ||
|
|
1e2d115768 | ||
|
|
53ba58d7e9 | ||
|
|
2a30afbe7d | ||
|
|
4d39c7041b | ||
|
|
c012b4740d | ||
|
|
55778eb810 | ||
|
|
fb8ab0b5fe | ||
|
|
fcce18b059 | ||
|
|
43a3d469e7 | ||
|
|
f730c24ecb | ||
|
|
b8522b8c17 | ||
|
|
b0d96040be | ||
|
|
5554675d03 | ||
|
|
c88401ef29 | ||
|
|
75f77ed929 | ||
|
|
4539ab2dcf | ||
|
|
9ad0561cac | ||
|
|
774052a983 | ||
|
|
3beb27acc2 | ||
|
|
5432eb7b77 | ||
|
|
181ff16409 | ||
|
|
682a0768b7 | ||
|
|
d29ebb706e | ||
|
|
4e6836c605 | ||
|
|
59c0d36c0b | ||
|
|
e10b2884c0 | ||
|
|
8fb54e815e | ||
|
|
3a14483568 | ||
|
|
fdd8efc12d | ||
|
|
3fef4b4a28 | ||
|
|
cea50a860e | ||
|
|
f9318bb1b3 | ||
|
|
d22f020eb5 | ||
|
|
c556d8123b | ||
|
|
a592833bd0 | ||
|
|
881da3db3b | ||
|
|
32eb9924e5 | ||
|
|
d2c06dd0ab | ||
|
|
75b8ed813f | ||
|
|
f811002c2b | ||
|
|
ca1b17863c | ||
|
|
644f5be6fe | ||
|
|
65fbb1dfb3 | ||
|
|
8597966187 | ||
|
|
6ddd70d21d | ||
|
|
d32112fe7e | ||
|
|
da858f0353 | ||
|
|
ba8b041698 | ||
|
|
d9fee5582a | ||
|
|
c9f17d54ee | ||
|
|
f5c1e12db4 | ||
|
|
18ceafeb60 | ||
|
|
67e93a6874 | ||
|
|
f6a83a3062 | ||
|
|
8a0e902bdd | ||
|
|
590fc3fc92 | ||
|
|
0d54b7696f | ||
|
|
6b1dadc35c | ||
|
|
86009543ed | ||
|
|
b728a78673 | ||
|
|
fb89cb80ac | ||
|
|
d0a986dd5a | ||
|
|
bb231e668b | ||
|
|
f53fa5c90f | ||
|
|
1f3e0d1f73 | ||
|
|
33a404f051 | ||
|
|
51e130c7a0 | ||
|
|
343ee04acb | ||
|
|
9372d1739a | ||
|
|
13555366e3 | ||
|
|
8162dafe16 | ||
|
|
0b6602b275 | ||
|
|
2cf9f64e8e | ||
|
|
37c0a813db | ||
|
|
a9269811dc | ||
|
|
0b353737ea | ||
|
|
a3fc1513e1 | ||
|
|
5886d73093 | ||
|
|
12adce9ac2 | ||
|
|
d8cbf0512b | ||
|
|
2bb2c2cde3 | ||
|
|
27fd9c5988 | ||
|
|
542673fcb0 | ||
|
|
bb9e57fa8b |
@@ -8,17 +8,16 @@ data/migrations_template.txt
|
||||
data/GeoLite2-City.*
|
||||
data/database.sqlite
|
||||
data/shlink-tests.db
|
||||
**/.gitignore
|
||||
CHANGELOG.md
|
||||
UPGRADE.md
|
||||
composer.lock
|
||||
vendor
|
||||
docs
|
||||
indocker
|
||||
docker-*
|
||||
php*
|
||||
infection.json
|
||||
phpstan.neon
|
||||
php*xml*
|
||||
infection.json
|
||||
**/test*
|
||||
build*
|
||||
.github
|
||||
hooks
|
||||
**/.*
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/Bug.md
vendored
2
.github/ISSUE_TEMPLATE/Bug.md
vendored
@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
|
||||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
2
.github/ISSUE_TEMPLATE/Question_Support.md
vendored
@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
|
||||
* Shlink Version: x.y.z
|
||||
* PHP Version: x.y.z
|
||||
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|SQLite (x.y.z)
|
||||
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
|
||||
|
||||
#### Summary
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
.idea
|
||||
build
|
||||
!hooks/build
|
||||
!docker/build
|
||||
composer.lock
|
||||
composer.phar
|
||||
vendor/
|
||||
|
||||
@@ -6,6 +6,9 @@ checks:
|
||||
code_rating: true
|
||||
duplication: true
|
||||
build:
|
||||
dependencies:
|
||||
override:
|
||||
- composer install --no-interaction --no-scripts --ignore-platform-reqs
|
||||
nodes:
|
||||
analysis:
|
||||
tests:
|
||||
|
||||
14
.travis.yml
14
.travis.yml
@@ -18,7 +18,7 @@ cache:
|
||||
|
||||
before_install:
|
||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
- yes | pecl install swoole
|
||||
- yes | pecl install swoole-4.4.15
|
||||
- phpenv config-rm xdebug.ini || return 0
|
||||
|
||||
install:
|
||||
@@ -37,15 +37,15 @@ script:
|
||||
|
||||
after_success:
|
||||
- rm -f 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://phar.phpunit.de/phpcov-7.0.2.phar
|
||||
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml
|
||||
- wget https://scrutinizer-ci.com/ocular.phar
|
||||
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml
|
||||
|
||||
# Before deploying, build dist file for current travis tag
|
||||
before_deploy:
|
||||
- rm -f ocular.phar
|
||||
- ./build.sh ${TRAVIS_TAG#?}
|
||||
- if [[ ! -z $TRAVIS_TAG && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi
|
||||
|
||||
deploy:
|
||||
- provider: releases
|
||||
@@ -56,3 +56,9 @@ deploy:
|
||||
on:
|
||||
tags: true
|
||||
php: '7.4'
|
||||
- provider: script
|
||||
script: bash ./docker/build
|
||||
on:
|
||||
all_branches: true
|
||||
condition: $TRAVIS_PULL_REQUEST == 'false'
|
||||
php: '7.4'
|
||||
|
||||
143
CHANGELOG.md
143
CHANGELOG.md
@@ -4,6 +4,149 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
#### Added
|
||||
|
||||
* [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server.
|
||||
|
||||
Thanks to that, Shlink will be able to publish events that can be consumed in real time.
|
||||
|
||||
For now, two topics (events) are published, when new visits occur. Both include a payload with the visit and the shortUrl:
|
||||
|
||||
* A visit occurs on any short URL: `https://shlink.io/new-visit`.
|
||||
* A visit occurs on short URLs with a specific short code: `https://shlink.io/new-visit/{shortCode}`.
|
||||
|
||||
The updates are only published when serving Shlink with swoole.
|
||||
|
||||
Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subsribe to updates.
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql.
|
||||
* [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled.
|
||||
* [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired.
|
||||
|
||||
|
||||
## 2.1.3 - 2020-04-09
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#712](https://github.com/shlinkio/shlink/issues/712) Fixed app set-up not clearing entities metadata cache.
|
||||
* [#711](https://github.com/shlinkio/shlink/issues/711) Fixed `HEAD` requests returning a duplicated `Content-Length` header.
|
||||
* [#716](https://github.com/shlinkio/shlink/issues/716) Fixed Twitter not properly displaying preview for final long URL.
|
||||
* [#717](https://github.com/shlinkio/shlink/issues/717) Fixed DB connection expiring on task workers when using swoole.
|
||||
* [#705](https://github.com/shlinkio/shlink/issues/705) Fixed how the short URL domain is inferred when generating QR codes, making sure the configured domain is respected even if the request is performed using a different one, and only when a custom domain is used, then that one is used instead.
|
||||
|
||||
|
||||
## 2.1.2 - 2020-03-29
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#696](https://github.com/shlinkio/shlink/issues/696) Updated to infection v0.16.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#700](https://github.com/shlinkio/shlink/issues/700) Fixed migration not working with postgres.
|
||||
* [#690](https://github.com/shlinkio/shlink/issues/690) Fixed tags being incorrectly sluggified when filtering short URL lists, making results not be the expected.
|
||||
|
||||
|
||||
## 2.1.1 - 2020-03-28
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#697](https://github.com/shlinkio/shlink/issues/697) Recovered `.htaccess` file that was unintentionally removed in v2.1.0, making Shlink unusable with Apache.
|
||||
|
||||
|
||||
## 2.1.0 - 2020-03-28
|
||||
|
||||
#### Added
|
||||
|
||||
* [#626](https://github.com/shlinkio/shlink/issues/626) Added support for Microsoft SQL Server.
|
||||
* [#556](https://github.com/shlinkio/shlink/issues/556) Short code lengths can now be customized, both globally and on a per-short URL basis.
|
||||
* [#541](https://github.com/shlinkio/shlink/issues/541) Added a request ID that is returned on `X-Request-Id` header, can be provided from outside and is set in log entries.
|
||||
* [#642](https://github.com/shlinkio/shlink/issues/642) IP geolocation is now performed over the non-anonymized IP address when using swoole.
|
||||
* [#521](https://github.com/shlinkio/shlink/issues/521) The long URL for any existing short URL can now be edited using the `PATCH /short-urls/{shortCode}` endpoint.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#656](https://github.com/shlinkio/shlink/issues/656) Updated to PHPUnit 9.
|
||||
* [#641](https://github.com/shlinkio/shlink/issues/641) Added two new flags to the `visit:locate` command, `--retry` and `--all`.
|
||||
|
||||
* When `--retry` is provided, it will try to re-locate visits which IP address was originally considered not found, in case it was a temporal issue.
|
||||
* When `--all` is provided together with `--retry`, it will try to re-locate all existing visits. A warning and confirmation are displayed, as this can have side effects.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#665](https://github.com/shlinkio/shlink/issues/665) Fixed `base_url_redirect_to` simplified config option not being properly parsed.
|
||||
* [#663](https://github.com/shlinkio/shlink/issues/663) Fixed Shlink allowing short URLs to be created with an empty custom slug.
|
||||
* [#678](https://github.com/shlinkio/shlink/issues/678) Fixed `db` commands not running in a non-interactive way.
|
||||
|
||||
|
||||
## 2.0.5 - 2020-02-09
|
||||
|
||||
#### Added
|
||||
|
||||
45
Dockerfile
45
Dockerfile
@@ -1,15 +1,14 @@
|
||||
FROM php:7.4.1-alpine3.10
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
FROM php:7.4.2-alpine3.11 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.0.0
|
||||
ARG SHLINK_VERSION=2.0.5
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.4.12
|
||||
ENV COMPOSER_VERSION 1.9.1
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
||||
RUN \
|
||||
# Install mysl and calendar
|
||||
# Install mysql and calendar
|
||||
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
|
||||
# Install sqlite
|
||||
apk add --no-cache sqlite-libs sqlite-dev && \
|
||||
@@ -24,24 +23,36 @@ RUN \
|
||||
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
|
||||
docker-php-ext-install -j"$(nproc)" zip gd
|
||||
|
||||
# Install swoole
|
||||
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
|
||||
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
|
||||
pecl install swoole-${SWOOLE_VERSION} && \
|
||||
docker-php-ext-enable swoole && \
|
||||
apk del .phpize-deps
|
||||
# Install swoole and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
|
||||
|
||||
# Install shlink
|
||||
FROM base as builder
|
||||
COPY . .
|
||||
RUN rm -rf ./docker && \
|
||||
wget https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar && \
|
||||
COPY --from=composer:1.10.1 /usr/bin/composer ./composer.phar
|
||||
RUN apk add --no-cache git && \
|
||||
php composer.phar install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction && \
|
||||
php composer.phar clear-cache && \
|
||||
rm composer.*
|
||||
rm -r docker composer.* && \
|
||||
sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
|
||||
|
||||
# Add shlink to the path to ease running it after container is created
|
||||
|
||||
# Prepare final image
|
||||
FROM base
|
||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||
|
||||
COPY --from=builder /etc/shlink .
|
||||
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
|
||||
RUN sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
|
||||
|
||||
# Expose swoole port
|
||||
EXPOSE 8080
|
||||
|
||||
15
README.md
15
README.md
@@ -4,8 +4,9 @@
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://scrutinizer-ci.com/g/shlinkio/shlink/?branch=master)
|
||||
[](https://packagist.org/packages/shlinkio/shlink)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://github.com/shlinkio/shlink/blob/master/LICENSE)
|
||||
[](https://acel.me/donate)
|
||||
[](https://slnk.to/donate)
|
||||
|
||||
A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
|
||||
|
||||
@@ -35,8 +36,8 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
||||
|
||||
First, make sure the host where you are going to run shlink fulfills these requirements:
|
||||
|
||||
* PHP 7.4 or greater with JSON, APCu, intl, curl, PDO and gd extensions enabled.
|
||||
* MySQL, MariaDB, PostgreSQL or SQLite.
|
||||
* PHP 7.4 or greater with JSON, curl, PDO and gd extensions enabled.
|
||||
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
|
||||
* The web server of your choice with PHP integration (Apache or Nginx recommended).
|
||||
|
||||
### Download
|
||||
@@ -67,7 +68,7 @@ In order to run Shlink, you will need a built version of the project. There are
|
||||
|
||||
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, PostgreSQL or Microsoft SQL Server, 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.
|
||||
* 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.**
|
||||
* 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.
|
||||
@@ -96,7 +97,7 @@ Once Shlink is configured, you need to expose it to the web, either by using a t
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
|
||||
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
}
|
||||
@@ -238,7 +239,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.
|
||||
|
||||
* **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.
|
||||
* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/documentation/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.
|
||||
|
||||
@@ -344,4 +345,6 @@ Those are configured during Shlink's installation or via env vars when using the
|
||||
|
||||
Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain.
|
||||
|
||||
---
|
||||
|
||||
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
||||
|
||||
@@ -9,7 +9,7 @@ echo 'Starting server...'
|
||||
vendor/bin/mezzio-swoole start -d
|
||||
sleep 2
|
||||
|
||||
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||
phpdbg -qrr vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
|
||||
testsExitCode=$?
|
||||
|
||||
vendor/bin/mezzio-swoole stop
|
||||
|
||||
3
build.sh
3
build.sh
@@ -19,13 +19,14 @@ mkdir -p "${builtcontent}"
|
||||
rsync -av * "${builtcontent}" \
|
||||
--exclude=*docker* \
|
||||
--exclude=Dockerfile \
|
||||
--include=.htaccess \
|
||||
--exclude-from=./.dockerignore
|
||||
cd "${builtcontent}"
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies with $composerBin..."
|
||||
${composerBin} self-update
|
||||
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
|
||||
${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction
|
||||
|
||||
# Delete development files
|
||||
echo 'Deleting dev files...'
|
||||
|
||||
@@ -17,13 +17,11 @@
|
||||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^1.0",
|
||||
"cakephp/chronos": "^1.2",
|
||||
"cocur/slugify": "^3.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"doctrine/migrations": "^2.2",
|
||||
"doctrine/orm": "^2.7",
|
||||
"endroid/qr-code": "^3.6",
|
||||
"firebase/php-jwt": "^4.0",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^6.5.1",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
@@ -34,38 +32,43 @@
|
||||
"laminas/laminas-paginator": "^2.8",
|
||||
"laminas/laminas-servicemanager": "^3.4",
|
||||
"laminas/laminas-stdlib": "^3.2",
|
||||
"lcobucci/jwt": "^4.0@alpha",
|
||||
"lstrojny/functional-php": "^1.9",
|
||||
"mezzio/mezzio": "^3.2",
|
||||
"mezzio/mezzio-fastroute": "^3.0",
|
||||
"mezzio/mezzio-helpers": "^5.3",
|
||||
"mezzio/mezzio-platesrenderer": "^2.1",
|
||||
"mezzio/mezzio-problem-details": "^1.1",
|
||||
"mezzio/mezzio-swoole": "^2.4",
|
||||
"mezzio/mezzio-swoole": "^2.6",
|
||||
"monolog/monolog": "^2.0",
|
||||
"nikolaposa/monolog-factory": "^3.0",
|
||||
"ocramius/proxy-manager": "^2.6.0",
|
||||
"ocramius/proxy-manager": "^2.7.0",
|
||||
"phly/phly-event-dispatcher": "^1.0",
|
||||
"php-middleware/request-id": "^4.0",
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"shlinkio/shlink-common": "^2.7.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.3",
|
||||
"shlinkio/shlink-installer": "^4.0.1",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.3.1",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "dev-master#e659cf9d9b5b3b131419e2f55f2e595f562baafc as 3.1.0",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||
"shlinkio/shlink-installer": "dev-master#f51a2186cf474fb5773b0ef74b8533878de9dd1e as 5.0.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.4",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
"symfony/lock": "^5.0",
|
||||
"symfony/mercure": "^0.3.0",
|
||||
"symfony/process": "^5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"devster/ubench": "^2.0",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.1.0",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.0",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.15.0",
|
||||
"phpstan/phpstan": "^0.12.3",
|
||||
"phpunit/phpunit": "^8.3",
|
||||
"infection/infection": "^0.16.1",
|
||||
"phpstan/phpstan": "^0.12.18",
|
||||
"phpunit/phpunit": "~9.0.1",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.1.0",
|
||||
"shlinkio/shlink-test-utils": "^1.3",
|
||||
"shlinkio/shlink-test-utils": "^1.4",
|
||||
"symfony/var-dumper": "^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -107,7 +110,7 @@
|
||||
"test:ci": [
|
||||
"@test:unit:ci",
|
||||
"@test:db:ci",
|
||||
"@test:api"
|
||||
"@test:api:ci"
|
||||
],
|
||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
||||
@@ -115,7 +118,8 @@
|
||||
"@test:db:sqlite",
|
||||
"@test:db:mysql",
|
||||
"@test:db:maria",
|
||||
"@test:db:postgres"
|
||||
"@test:db:postgres",
|
||||
"@test:db:ms"
|
||||
],
|
||||
"test:db:ci": [
|
||||
"@test:db:sqlite",
|
||||
@@ -126,10 +130,12 @@
|
||||
"test:db:mysql": "DB_DRIVER=mysql 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:ms": "DB_DRIVER=mssql composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:api:ci": "@test:api --coverage-php build/coverage-api.cov",
|
||||
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
|
||||
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||
"infect:ci": "@infect --coverage=build",
|
||||
"infect:ci": "@infect --coverage=build --skip-initial-tests",
|
||||
"infect:show": "@infect --show-mutations",
|
||||
"infect:test": [
|
||||
"@test:unit:ci",
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
// When running tests, any mysql-specific option can interfere with other drivers
|
||||
$driverOptions = env('APP_ENV') === 'test' ? [] : [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
@@ -10,9 +18,7 @@ return [
|
||||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
],
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -8,28 +8,33 @@ return [
|
||||
|
||||
'installer' => [
|
||||
'enabled_options' => [
|
||||
Option\DatabaseDriverConfigOption::class,
|
||||
Option\DatabaseNameConfigOption::class,
|
||||
Option\DatabaseHostConfigOption::class,
|
||||
Option\DatabasePortConfigOption::class,
|
||||
Option\DatabaseUserConfigOption::class,
|
||||
Option\DatabasePasswordConfigOption::class,
|
||||
Option\DatabaseSqlitePathConfigOption::class,
|
||||
Option\DatabaseMySqlOptionsConfigOption::class,
|
||||
Option\ShortDomainHostConfigOption::class,
|
||||
Option\ShortDomainSchemaConfigOption::class,
|
||||
Option\ValidateUrlConfigOption::class,
|
||||
Option\VisitsWebhooksConfigOption::class,
|
||||
Option\BaseUrlRedirectConfigOption::class,
|
||||
Option\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Regular404RedirectConfigOption::class,
|
||||
Option\Database\DatabaseDriverConfigOption::class,
|
||||
Option\Database\DatabaseNameConfigOption::class,
|
||||
Option\Database\DatabaseHostConfigOption::class,
|
||||
Option\Database\DatabasePortConfigOption::class,
|
||||
Option\Database\DatabaseUserConfigOption::class,
|
||||
Option\Database\DatabasePasswordConfigOption::class,
|
||||
Option\Database\DatabaseSqlitePathConfigOption::class,
|
||||
Option\Database\DatabaseMySqlOptionsConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainHostConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
|
||||
Option\UrlShortener\ValidateUrlConfigOption::class,
|
||||
Option\Visit\VisitsWebhooksConfigOption::class,
|
||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Redirect\Regular404RedirectConfigOption::class,
|
||||
Option\DisableTrackParamConfigOption::class,
|
||||
Option\CheckVisitsThresholdConfigOption::class,
|
||||
Option\VisitsThresholdConfigOption::class,
|
||||
Option\Visit\CheckVisitsThresholdConfigOption::class,
|
||||
Option\Visit\VisitsThresholdConfigOption::class,
|
||||
Option\BasePathConfigOption::class,
|
||||
Option\TaskWorkerNumConfigOption::class,
|
||||
Option\WebWorkerNumConfigOption::class,
|
||||
Option\Worker\TaskWorkerNumConfigOption::class,
|
||||
Option\Worker\WebWorkerNumConfigOption::class,
|
||||
Option\RedisServersConfigOption::class,
|
||||
Option\UrlShortener\ShortCodeLengthOption::class,
|
||||
Option\Mercure\EnableMercureConfigOption::class,
|
||||
Option\Mercure\MercurePublicUrlConfigOption::class,
|
||||
Option\Mercure\MercureInternalUrlConfigOption::class,
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
||||
@@ -8,7 +8,7 @@ use Shlinkio\Shlink\Common\Lock\RetryLockStoreDelegatorFactory;
|
||||
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
$localLockFactory = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||
|
||||
return [
|
||||
|
||||
@@ -21,7 +21,7 @@ return [
|
||||
Lock\Store\FlockStore::class => ConfigAbstractFactory::class,
|
||||
Lock\Store\RedisStore::class => ConfigAbstractFactory::class,
|
||||
Lock\LockFactory::class => ConfigAbstractFactory::class,
|
||||
$localLockFactory => ConfigAbstractFactory::class,
|
||||
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
|
||||
],
|
||||
'aliases' => [
|
||||
// With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default
|
||||
@@ -44,7 +44,7 @@ return [
|
||||
Lock\Store\FlockStore::class => ['config.locks.locks_dir'],
|
||||
Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME],
|
||||
Lock\LockFactory::class => ['lock_store'],
|
||||
$localLockFactory => ['local_lock_store'],
|
||||
LOCAL_LOCK_FACTORY => ['local_lock_store'],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ use Monolog\Handler;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Processor;
|
||||
use MonologFactory\DiContainerLoggerFactory;
|
||||
use PhpMiddleware\RequestId;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use const PHP_EOL;
|
||||
@@ -20,11 +21,12 @@ $processors = [
|
||||
'psr3' => [
|
||||
'name' => Processor\PsrLogMessageProcessor::class,
|
||||
],
|
||||
'request_id' => RequestId\MonologProcessor::class,
|
||||
];
|
||||
$formatter = [
|
||||
'name' => Formatter\LineFormatter::class,
|
||||
'params' => [
|
||||
'format' => '[%datetime%] %channel%.%level_name% - %message%' . PHP_EOL,
|
||||
'format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%' . PHP_EOL,
|
||||
'allow_inline_line_breaks' => true,
|
||||
],
|
||||
];
|
||||
@@ -80,6 +82,7 @@ return [
|
||||
'swoole-http-server' => [
|
||||
'logger' => [
|
||||
'logger-name' => 'Logger_Access',
|
||||
'format' => '%h %l %u "%r" %>s %b',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
36
config/autoload/mercure.global.php
Normal file
36
config/autoload/mercure.global.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
|
||||
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
|
||||
use Symfony\Component\Mercure\Publisher;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
|
||||
return [
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => null,
|
||||
'internal_hub_url' => null,
|
||||
'jwt_secret' => null,
|
||||
'jwt_issuer' => 'Shlink',
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'delegators' => [
|
||||
LcobucciJwtProvider::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
Publisher::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
],
|
||||
'lazy_services' => [
|
||||
'class_map' => [
|
||||
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
|
||||
Publisher::class => PublisherInterface::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
13
config/autoload/mercure.local.php.dist
Normal file
13
config/autoload/mercure.local.php.dist
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => 'http://localhost:8001',
|
||||
'internal_hub_url' => 'http://shlink_mercure_proxy',
|
||||
'jwt_secret' => 'mercure_jwt_key',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -5,15 +5,17 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Laminas\Stratigility\Middleware\ErrorHandler;
|
||||
use Mezzio;
|
||||
use Mezzio\Helper;
|
||||
use Mezzio\ProblemDetails;
|
||||
use Mezzio\Router;
|
||||
use PhpMiddleware\RequestId\RequestIdMiddleware;
|
||||
|
||||
return [
|
||||
|
||||
'middleware_pipeline' => [
|
||||
'error-handler' => [
|
||||
'middleware' => [
|
||||
Mezzio\Helper\ContentLengthMiddleware::class,
|
||||
Helper\ContentLengthMiddleware::class,
|
||||
ErrorHandler::class,
|
||||
],
|
||||
],
|
||||
@@ -21,6 +23,7 @@ return [
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
Rest\Middleware\CrossDomainMiddleware::class,
|
||||
RequestIdMiddleware::class,
|
||||
ProblemDetails\ProblemDetailsMiddleware::class,
|
||||
],
|
||||
],
|
||||
@@ -33,14 +36,15 @@ return [
|
||||
|
||||
'routing' => [
|
||||
'middleware' => [
|
||||
Mezzio\Router\Middleware\RouteMiddleware::class,
|
||||
Router\Middleware\RouteMiddleware::class,
|
||||
Router\Middleware\ImplicitHeadMiddleware::class,
|
||||
],
|
||||
],
|
||||
|
||||
'rest' => [
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
Mezzio\Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||
Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||
Rest\Middleware\BodyParserMiddleware::class,
|
||||
Rest\Middleware\AuthenticationMiddleware::class,
|
||||
],
|
||||
@@ -48,7 +52,7 @@ return [
|
||||
|
||||
'dispatch' => [
|
||||
'middleware' => [
|
||||
Mezzio\Router\Middleware\DispatchMiddleware::class,
|
||||
Router\Middleware\DispatchMiddleware::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -65,4 +69,5 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
38
config/autoload/request_id.global.php
Normal file
38
config/autoload/request_id.global.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use PhpMiddleware\RequestId;
|
||||
|
||||
return [
|
||||
|
||||
'request_id' => [
|
||||
'allow_override' => true,
|
||||
'header_name' => 'X-Request-Id',
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'factories' => [
|
||||
RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class,
|
||||
RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class,
|
||||
RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class,
|
||||
RequestId\MonologProcessor::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
RequestId\RequestIdProviderFactory::class => [
|
||||
RequestId\Generator\RamseyUuid4StaticGenerator::class,
|
||||
'config.request_id.allow_override',
|
||||
'config.request_id.header_name',
|
||||
],
|
||||
RequestId\RequestIdMiddleware::class => [
|
||||
RequestId\RequestIdProviderFactory::class,
|
||||
'config.request_id.header_name',
|
||||
],
|
||||
RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
return [
|
||||
|
||||
'url_shortener' => [
|
||||
@@ -11,6 +13,7 @@ return [
|
||||
],
|
||||
'validate_url' => false,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink;
|
||||
|
||||
use Laminas\ConfigAggregator;
|
||||
use Laminas\ZendFrameworkBridge;
|
||||
use Mezzio;
|
||||
use Mezzio\ProblemDetails;
|
||||
|
||||
@@ -19,17 +18,17 @@ return (new ConfigAggregator\ConfigAggregator([
|
||||
Mezzio\Swoole\ConfigProvider::class,
|
||||
ProblemDetails\ConfigProvider::class,
|
||||
Common\ConfigProvider::class,
|
||||
Config\ConfigProvider::class,
|
||||
IpGeolocation\ConfigProvider::class,
|
||||
EventDispatcher\ConfigProvider::class,
|
||||
Core\ConfigProvider::class,
|
||||
CLI\ConfigProvider::class,
|
||||
Rest\ConfigProvider::class,
|
||||
EventDispatcher\ConfigProvider::class,
|
||||
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
|
||||
env('APP_ENV') === 'test'
|
||||
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
|
||||
: new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
|
||||
], 'data/cache/app_config.php', [
|
||||
ZendFrameworkBridge\ConfigPostProcessor::class,
|
||||
Core\Config\SimplifiedConfigParser::class,
|
||||
Core\Config\BasePathPrefixer::class,
|
||||
Core\Config\DeprecatedConfigParser::class,
|
||||
|
||||
@@ -5,13 +5,16 @@ declare(strict_types=1);
|
||||
use Laminas\ServiceManager\ServiceManager;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||
|
||||
chdir(dirname(__DIR__));
|
||||
|
||||
require 'vendor/autoload.php';
|
||||
|
||||
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
|
||||
if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) {
|
||||
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
|
||||
// It needs to be placed here as individual config files will not be loaded once config is cached
|
||||
if (! class_exists(LOCAL_LOCK_FACTORY)) {
|
||||
class_alias(Lock\LockFactory::class, LOCAL_LOCK_FACTORY);
|
||||
}
|
||||
|
||||
// Build container
|
||||
|
||||
@@ -35,6 +35,7 @@ $buildDbConnection = function (): array {
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
],
|
||||
],
|
||||
'postgres' => [
|
||||
@@ -45,6 +46,13 @@ $buildDbConnection = function (): array {
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
'mssql' => [
|
||||
'driver' => 'pdo_sqlsrv',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms',
|
||||
'user' => 'sa',
|
||||
'password' => $isCi ? '' : 'Passw0rd!',
|
||||
'dbname' => 'shlink_test',
|
||||
],
|
||||
];
|
||||
$driverConfigMap['maria'] = $driverConfigMap['mysql'];
|
||||
|
||||
@@ -72,13 +80,17 @@ return [
|
||||
'process-name' => 'shlink_test',
|
||||
'options' => [
|
||||
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
|
||||
'worker_num' => 1,
|
||||
'task_worker_num' => 1,
|
||||
'enable_coroutine' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => null,
|
||||
'internal_hub_url' => null,
|
||||
'jwt_secret' => null,
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'services' => [
|
||||
'shlink_test_api_client' => new Client([
|
||||
|
||||
17
data/infra/mercure_proxy_vhost.conf
Normal file
17
data/infra/mercure_proxy_vhost.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
error_log /home/shlink/www/data/infra/nginx/mercure_proxy.error.log;
|
||||
|
||||
location / {
|
||||
proxy_pass http://shlink_mercure;
|
||||
proxy_read_timeout 24h;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
## Be sure to set USE_FORWARDED_HEADERS=1 to allow the hub to use those headers ##
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM php:7.4.1-fpm-alpine3.10
|
||||
FROM php:7.4.2-fpm-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
@@ -65,6 +65,18 @@ RUN docker-php-ext-configure xdebug\
|
||||
# cleanup
|
||||
RUN rm /tmp/xdebug.tar.gz
|
||||
|
||||
# Install sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
RUN chmod +x composer.phar
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM php:7.4.1-alpine3.10
|
||||
FROM php:7.4.2-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.4.12
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
|
||||
RUN apk update
|
||||
|
||||
@@ -66,12 +66,17 @@ RUN docker-php-ext-configure inotify\
|
||||
# cleanup
|
||||
RUN rm /tmp/inotify.tar.gz
|
||||
|
||||
# Install swoole
|
||||
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
|
||||
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
|
||||
pecl install swoole-${SWOOLE_VERSION} && \
|
||||
docker-php-ext-enable swoole && \
|
||||
apk del .phpize-deps
|
||||
# Install swoole and mssql driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
|
||||
45
data/migrations/Version20200323190014.php
Normal file
45
data/migrations/Version20200323190014.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20200323190014 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visitLocations = $schema->getTable('visit_locations');
|
||||
$this->skipIf($visitLocations->hasColumn('is_empty'));
|
||||
|
||||
$visitLocations->addColumn('is_empty', Types::BOOLEAN, ['default' => false]);
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
$qb->update('visit_locations')
|
||||
->set('is_empty', ':isEmpty')
|
||||
->where($qb->expr()->eq('country_code', ':emptyString'))
|
||||
->andWhere($qb->expr()->eq('country_name', ':emptyString'))
|
||||
->andWhere($qb->expr()->eq('region_name', ':emptyString'))
|
||||
->andWhere($qb->expr()->eq('city_name', ':emptyString'))
|
||||
->andWhere($qb->expr()->eq('timezone', ':emptyString'))
|
||||
->andWhere($qb->expr()->eq('lat', 0))
|
||||
->andWhere($qb->expr()->eq('lon', 0))
|
||||
->setParameter('isEmpty', true)
|
||||
->setParameter('emptyString', '')
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visitLocations = $schema->getTable('visit_locations');
|
||||
$this->skipIf(!$visitLocations->hasColumn('is_empty'));
|
||||
|
||||
$visitLocations->dropColumn('is_empty');
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ version: '3'
|
||||
services:
|
||||
shlink_nginx:
|
||||
container_name: shlink_nginx
|
||||
image: nginx:1.17.6-alpine
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
@@ -25,7 +25,12 @@ services:
|
||||
- shlink_db
|
||||
- shlink_db_postgres
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
shlink_swoole:
|
||||
container_name: shlink_swoole
|
||||
@@ -42,7 +47,12 @@ services:
|
||||
- shlink_db
|
||||
- shlink_db_postgres
|
||||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
shlink_db:
|
||||
container_name: shlink_db
|
||||
@@ -82,8 +92,38 @@ services:
|
||||
MYSQL_DATABASE: shlink
|
||||
MYSQL_INITDB_SKIP_TZINFO: 1
|
||||
|
||||
shlink_db_ms:
|
||||
container_name: shlink_db_ms
|
||||
image: mcr.microsoft.com/mssql/server:2019-latest
|
||||
ports:
|
||||
- "1433:1433"
|
||||
environment:
|
||||
ACCEPT_EULA: Y
|
||||
SA_PASSWORD: "Passw0rd!"
|
||||
|
||||
shlink_redis:
|
||||
container_name: shlink_redis
|
||||
image: redis:5.0-alpine
|
||||
ports:
|
||||
- "6380:6379"
|
||||
|
||||
shlink_mercure_proxy:
|
||||
container_name: shlink_mercure_proxy
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8001:80"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
- ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
|
||||
links:
|
||||
- shlink_mercure
|
||||
|
||||
shlink_mercure:
|
||||
container_name: shlink_mercure
|
||||
image: dunglas/mercure:v0.8
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
JWT_KEY: "mercure_jwt_key"
|
||||
USE_FORWARDED_HEADERS: "1"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Shlink Docker image
|
||||
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
|
||||
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
|
||||
@@ -38,10 +37,10 @@ Or you can list all tags with:
|
||||
docker exec -it shlink_container shlink tag:list
|
||||
```
|
||||
|
||||
Or process remaining visits with:
|
||||
Or locate remaining visits with:
|
||||
|
||||
```bash
|
||||
docker exec -it shlink_container shlink visit:process
|
||||
docker exec -it shlink_container shlink visit:locate
|
||||
```
|
||||
|
||||
All shlink commands will work the same way.
|
||||
@@ -56,9 +55,9 @@ docker exec -it shlink_container shlink
|
||||
|
||||
The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
|
||||
|
||||
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB or PostgreSQL database.
|
||||
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL, MariaDB, PostgreSQL or Microsoft SQL Server database.
|
||||
|
||||
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria** or **postgres** to prevent the sqlite database to be used.
|
||||
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql**, **maria**, **postgres** or **mssql** to prevent the sqlite database to be used.
|
||||
* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**.
|
||||
* `DB_USER`: **[Mandatory]**. The username credential for the database server.
|
||||
* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
|
||||
@@ -67,24 +66,80 @@ It is possible to use a set of env vars to make this shlink instance interact wi
|
||||
* Default value is based on the value provided for `DB_DRIVER`:
|
||||
* **mysql** or **maria** -> `3306`
|
||||
* **postgres** -> `5432`
|
||||
* **mssql** -> `1433`
|
||||
|
||||
> PostgreSQL is supported since v1.16.1 of this image. Do not try to use it with previous versions.
|
||||
> PostgreSQL is supported since v1.16.1 and Microsoft SQL server since v2.1.0. Do not try to use them with previous versions.
|
||||
|
||||
Taking this into account, you could run shlink on a local docker service like this:
|
||||
|
||||
```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:stable
|
||||
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:
|
||||
|
||||
```bash
|
||||
docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink:stable
|
||||
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.
|
||||
|
||||
## Supported env vars
|
||||
## Other integrations
|
||||
|
||||
### Use an external redis server
|
||||
|
||||
If you plan to run more than one Shlink instance, there are some resources that should be shared ([Multi instance considerations](#multi-instance-considerations)).
|
||||
|
||||
One of those resources are the locks Shlink generates to prevent some operations to be run more than once in parallel (in the future, these redis servers could be used for other caching operations).
|
||||
|
||||
In order to share those locks, you should use an external redis server (or a cluster of redis servers), by providing the `REDIS_SERVERS` env var.
|
||||
|
||||
It can be either one server name or a comma-separated list of servers.
|
||||
|
||||
> If more than one redis server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial).
|
||||
|
||||
### Integrate with a mercure hub server
|
||||
|
||||
One way to get real time updates when certain events happen in Shlink is by integrating it with a [mercure hub](https://mercure.rocks/) server.
|
||||
|
||||
If you do that, Shlink will publish updates and other clients can subscribe to those.
|
||||
|
||||
There are three env vars you need to provide if you want to enable this:
|
||||
|
||||
* `MERCURE_PUBLIC_HUB_URL`: **[Mandatory]**. The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates.
|
||||
* `MERCURE_INTERNAL_HUB_URL`: **[Optional]**. An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided, the `MERCURE_PUBLIC_HUB_URL` one will be used to publish updates.
|
||||
* `MERCURE_JWT_SECRET`: **[Mandatory]**. The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
|
||||
|
||||
So in order to run shlink with mercure integration, you would do it like this:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
--name shlink \
|
||||
-p 8080:8080 \
|
||||
-e SHORT_DOMAIN_HOST=doma.in \
|
||||
-e SHORT_DOMAIN_SCHEMA=https \
|
||||
-e "MERCURE_PUBLIC_HUB_URL=https://example.com"
|
||||
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local"
|
||||
-e MERCURE_JWT_SECRET=super_secret_key
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
## All supported env vars
|
||||
|
||||
A few env vars have been already used in previous examples, but this image supports others that can be used to customize its behavior.
|
||||
|
||||
@@ -92,7 +147,7 @@ This is the complete list of supported env vars:
|
||||
|
||||
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
|
||||
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
|
||||
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria** or **postgres**.
|
||||
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql**, **maria**, **postgres** or **mssql**.
|
||||
* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**.
|
||||
* `DB_USER`: The username credential to be used when using an external database driver.
|
||||
* `DB_PASSWORD`: The password credential to be used when using an external database driver.
|
||||
@@ -101,6 +156,7 @@ This is the complete list of supported env vars:
|
||||
* Default value is based on the value provided for `DB_DRIVER`:
|
||||
* **mysql** or **maria** -> `3306`
|
||||
* **postgres** -> `5432`
|
||||
* **mssql** -> `1433`
|
||||
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
|
||||
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
|
||||
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x is returned (after following redirects) when trying to shorten a URL. Defaults to `false`.
|
||||
@@ -111,13 +167,11 @@ This is the complete list of supported env vars:
|
||||
* `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.
|
||||
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
|
||||
* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4.
|
||||
* `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.
|
||||
|
||||
If more than one server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial).
|
||||
|
||||
In the future, these redis servers could be used for other caching operations performed by shlink.
|
||||
* `MERCURE_PUBLIC_HUB_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates.
|
||||
* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates.
|
||||
* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
|
||||
|
||||
An example using all env vars could look like this:
|
||||
|
||||
@@ -144,6 +198,10 @@ docker run \
|
||||
-e WEB_WORKER_NUM=64 \
|
||||
-e TASK_WORKER_NUM=32 \
|
||||
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
||||
-e DEFAULT_SHORT_CODES_LENGTH=6 \
|
||||
-e "MERCURE_PUBLIC_HUB_URL=https://example.com" \
|
||||
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \
|
||||
-e MERCURE_JWT_SECRET=super_secret_key \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
@@ -168,6 +226,7 @@ The whole configuration should have this format, but it can be split into multip
|
||||
"base_path": "/my-campaign",
|
||||
"web_worker_num": 64,
|
||||
"task_worker_num": 32,
|
||||
"default_short_codes_length": 6,
|
||||
"redis_servers": [
|
||||
"tcp://172.20.0.1:6379",
|
||||
"tcp://172.20.0.2:6379"
|
||||
@@ -183,7 +242,10 @@ The whole configuration should have this format, but it can be split into multip
|
||||
"password": "123abc",
|
||||
"host": "something.rds.amazonaws.com",
|
||||
"port": "3306"
|
||||
}
|
||||
},
|
||||
"mercure_public_hub_url": "https://example.com",
|
||||
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local",
|
||||
"mercure_jwt_secret": "super_secret_key"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
15
docker/build
Executable file
15
docker/build
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
||||
|
||||
# If there is a tag, regardless the branch, build that docker tag and also "stable"
|
||||
if [[ ! -z $TRAVIS_TAG ]]; then
|
||||
docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable .
|
||||
docker push shlinkio/shlink:${TRAVIS_TAG#?}
|
||||
docker push shlinkio/shlink:stable
|
||||
# If build branch is develop, build latest (on master, when there's no tag, do not build anything)
|
||||
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
|
||||
docker build -t shlinkio/shlink:latest .
|
||||
docker push shlinkio/shlink:latest
|
||||
fi
|
||||
@@ -11,16 +11,21 @@ use function explode;
|
||||
use function Functional\contains;
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
$helper = new class {
|
||||
private const DB_DRIVERS_MAP = [
|
||||
'mysql' => 'pdo_mysql',
|
||||
'maria' => 'pdo_mysql',
|
||||
'postgres' => 'pdo_pgsql',
|
||||
'mssql' => 'pdo_sqlsrv',
|
||||
];
|
||||
private const DB_PORTS_MAP = [
|
||||
'mysql' => '3306',
|
||||
'maria' => '3306',
|
||||
'postgres' => '5432',
|
||||
'mssql' => '1433',
|
||||
];
|
||||
|
||||
public function getDbConfig(): array
|
||||
@@ -36,6 +41,8 @@ $helper = new class {
|
||||
$driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [
|
||||
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
|
||||
1002 => 'SET NAMES utf8',
|
||||
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
|
||||
1000 => true,
|
||||
];
|
||||
return [
|
||||
'driver' => self::DB_DRIVERS_MAP[$driver],
|
||||
@@ -68,6 +75,23 @@ $helper = new class {
|
||||
$redisServers = env('REDIS_SERVERS');
|
||||
return $redisServers === null ? null : ['servers' => $redisServers];
|
||||
}
|
||||
|
||||
public function getDefaultShortCodesLength(): int
|
||||
{
|
||||
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
|
||||
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
|
||||
}
|
||||
|
||||
public function getMercureConfig(): array
|
||||
{
|
||||
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
|
||||
|
||||
return [
|
||||
'public_hub_url' => $publicUrl,
|
||||
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
|
||||
'jwt_secret' => env('MERCURE_JWT_SECRET'),
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
@@ -94,6 +118,7 @@ return [
|
||||
],
|
||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
],
|
||||
|
||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||
@@ -135,4 +160,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'mercure' => $helper->getMercureConfig(),
|
||||
|
||||
];
|
||||
|
||||
@@ -12,6 +12,9 @@ php bin/cli db:migrate -n -q
|
||||
echo "Generating proxies..."
|
||||
php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
|
||||
|
||||
echo "Clearing entities cache..."
|
||||
php vendor/doctrine/orm/bin/doctrine.php orm:clear-cache:metadata -n -q
|
||||
|
||||
# When restarting the container, swoole might think it is already in execution
|
||||
# This forces the app to be started every second until the exit code is 0
|
||||
until php vendor/mezzio/mezzio-swoole/bin/mezzio-swoole start; do sleep 1 ; done
|
||||
|
||||
210
docs/async-api/async-api.json
Normal file
210
docs/async-api/async-api.json
Normal file
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"asyncapi": "2.0.0",
|
||||
"info": {
|
||||
"title": "Shlink",
|
||||
"version": "2.0.0",
|
||||
"description": "Shlink, the self-hosted URL shortener",
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "https://github.com/shlinkio/shlink/blob/develop/LICENSE"
|
||||
}
|
||||
},
|
||||
"defaultContentType": "application/json",
|
||||
"channels": {
|
||||
"http://shlink.io/new-visit": {
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new visit occurring on any short URL.",
|
||||
"operationId": "newVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shortUrl": {
|
||||
"$ref": "#/components/schemas/ShortUrl"
|
||||
},
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/Visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http://shlink.io/new-visit/{shortCode}": {
|
||||
"parameters": {
|
||||
"shortCode": {
|
||||
"description": "The short code of the short URL",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new visit occurring on a specific short URL.",
|
||||
"operationId": "newShortUrlVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shortUrl": {
|
||||
"$ref": "#/components/schemas/ShortUrl"
|
||||
},
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/Visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ShortUrl": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shortCode": {
|
||||
"type": "string",
|
||||
"description": "The short code for this short URL."
|
||||
},
|
||||
"shortUrl": {
|
||||
"type": "string",
|
||||
"description": "The short URL."
|
||||
},
|
||||
"longUrl": {
|
||||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the short URL was created in ISO format."
|
||||
},
|
||||
"visitsCount": {
|
||||
"type": "integer",
|
||||
"description": "The number of visits that this short URL has recieved."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "A list of tags applied to this short URL"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/ShortUrlMeta"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 328,
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
],
|
||||
"meta": {
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": "example.com"
|
||||
}
|
||||
},
|
||||
"ShortUrlMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validSince",
|
||||
"validUntil",
|
||||
"maxVisits"
|
||||
],
|
||||
"properties": {
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"validUntil": {
|
||||
"description": "The date (in ISO-8601 format) until which this short code will be valid",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"maxVisits": {
|
||||
"description": "The maximum number of allowed visits for this short code",
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Visit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"referer": {
|
||||
"type": "string",
|
||||
"description": "The origin from which the visit was performed"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the visit was performed"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "string",
|
||||
"description": "The user agent from which the visit was performed"
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "#/components/schemas/VisitLocation"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisitLocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cityName": {
|
||||
"type": "string"
|
||||
},
|
||||
"countryCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"countryName": {
|
||||
"type": "string"
|
||||
},
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"regionName": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
docs/swagger/definitions/MercureInfo.json
Normal file
18
docs/swagger/definitions/MercureInfo.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["mercureHubUrl", "jwt", "jwtExpiration"],
|
||||
"properties": {
|
||||
"mercureHubUrl": {
|
||||
"type": "string",
|
||||
"description": "The public URL of the mercure hub that can be used to get real-time updates published by Shlink"
|
||||
},
|
||||
"jwt": {
|
||||
"type": "string",
|
||||
"description": "A JWT with subscribe permissions which is valid with the mercure hub"
|
||||
},
|
||||
"jwtExpiration": {
|
||||
"type": "string",
|
||||
"description": "The date (in ISO-8601 format) in which the JWT will expire"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,10 @@
|
||||
"domain": {
|
||||
"description": "The domain to which the short URL will be attached",
|
||||
"type": "string"
|
||||
},
|
||||
"shortCodeLength": {
|
||||
"description": "The length for generated short code. It has to be at least 4 and defaults to 5. It will be ignored when customSlug is provided",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"longUrl": {
|
||||
"description": "The long URL this short URL will redirect to",
|
||||
"type": "string"
|
||||
},
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string"
|
||||
@@ -157,6 +161,7 @@
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"longUrl",
|
||||
"validSince",
|
||||
"validUntil",
|
||||
"maxVisits"
|
||||
|
||||
67
docs/swagger/paths/v2_mercure-info.json
Normal file
67
docs/swagger/paths/v2_mercure-info.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"get": {
|
||||
"operationId": "mercureInfo",
|
||||
"tags": [
|
||||
"Integrations"
|
||||
],
|
||||
"summary": "Get mercure integration info",
|
||||
"description": "Returns information to consume updates published by Shlink on a mercure hub. https://mercure.rocks/",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The mercure integration info",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/MercureInfo.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"mercureHubUrl": "https://example.com/.well-known/mercure",
|
||||
"jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ",
|
||||
"jwtExpiration": "2020-04-15T12:18:52+02:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "This Shlink instance is not integrated with a mercure hub",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"title": "Mercure integration not configured",
|
||||
"type": "MERCURE_NOT_CONFIGURED",
|
||||
"detail": "This Shlink instance is not integrated with a mercure hub.",
|
||||
"status": 501
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
|
||||
"externalDocs": {
|
||||
"url": "https://shlink.io/api-docs",
|
||||
"url": "https://shlink.io/documentation/api-docs",
|
||||
"description": "Find more info on how to start using this API here"
|
||||
},
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/mercure-info": {
|
||||
"$ref": "paths/v2_mercure-info.json"
|
||||
},
|
||||
|
||||
"/rest/health": {
|
||||
"$ref": "paths/health.json"
|
||||
},
|
||||
|
||||
10
hooks/build
10
hooks/build
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
|
||||
if [[ ${SOURCE_BRANCH} == 'develop' ]]; then
|
||||
SHLINK_RELEASE='latest'
|
||||
else
|
||||
SHLINK_RELEASE=${SOURCE_BRANCH#?}
|
||||
fi
|
||||
|
||||
docker build --build-arg SHLINK_VERSION=${SHLINK_RELEASE} -t ${IMAGE_NAME} .
|
||||
@@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
@@ -19,6 +20,8 @@ use Symfony\Component\Console as SymfonyCli;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use const Shlinkio\Shlink\Core\LOCAL_LOCK_FACTORY;
|
||||
|
||||
return [
|
||||
|
||||
'dependencies' => [
|
||||
@@ -52,16 +55,20 @@ return [
|
||||
],
|
||||
|
||||
ConfigAbstractFactory::class => [
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'],
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [
|
||||
Service\UrlShortener::class,
|
||||
'config.url_shortener.domain',
|
||||
'config.url_shortener.default_short_codes_length',
|
||||
],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
Command\Visit\LocateVisitsCommand::class => [
|
||||
Service\VisitService::class,
|
||||
Visit\VisitLocator::class,
|
||||
IpLocationResolverInterface::class,
|
||||
LockFactory::class,
|
||||
GeolocationDbUpdater::class,
|
||||
|
||||
@@ -11,8 +11,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
|
||||
use function array_unshift;
|
||||
|
||||
abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||
{
|
||||
private ProcessHelper $processHelper;
|
||||
@@ -27,7 +25,7 @@ abstract class AbstractDatabaseCommand extends AbstractLockedCommand
|
||||
|
||||
protected function runPhpCommand(OutputInterface $output, array $command): void
|
||||
{
|
||||
array_unshift($command, $this->phpBinary);
|
||||
$command = [$this->phpBinary, ...$command, '--no-interaction'];
|
||||
$this->processHelper->mustRun($output, $command);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,12 +30,14 @@ class GenerateShortUrlCommand extends Command
|
||||
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private array $domainConfig;
|
||||
private int $defaultShortCodeLength;
|
||||
|
||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
|
||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->domainConfig = $domainConfig;
|
||||
$this->defaultShortCodeLength = $defaultShortCodeLength;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -87,6 +89,12 @@ class GenerateShortUrlCommand extends Command
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain to which this short URL will be attached.',
|
||||
)
|
||||
->addOption(
|
||||
'shortCodeLength',
|
||||
'l',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The length for generated short code (it will be ignored if --customSlug was provided).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,6 +125,7 @@ class GenerateShortUrlCommand extends Command
|
||||
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
|
||||
$customSlug = $input->getOption('customSlug');
|
||||
$maxVisits = $input->getOption('maxVisits');
|
||||
$shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
|
||||
|
||||
try {
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
@@ -129,6 +138,7 @@ class GenerateShortUrlCommand extends Command
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
@@ -76,7 +77,7 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||
|
||||
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
|
||||
$rowData = $visit->jsonSerialize();
|
||||
$rowData['country'] = $visit->getVisitLocation()->getCountryName();
|
||||
$rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName();
|
||||
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']);
|
||||
});
|
||||
ShlinkTable::fromOutput($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows);
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Exception;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
@@ -14,12 +13,15 @@ use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Service\VisitServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitLocatorInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
@@ -27,11 +29,11 @@ use Throwable;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class LocateVisitsCommand extends AbstractLockedCommand
|
||||
class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocationHelperInterface
|
||||
{
|
||||
public const NAME = 'visit:locate';
|
||||
|
||||
private VisitServiceInterface $visitService;
|
||||
private VisitLocatorInterface $visitLocator;
|
||||
private IpLocationResolverInterface $ipLocationResolver;
|
||||
private GeolocationDbUpdaterInterface $dbUpdater;
|
||||
|
||||
@@ -39,13 +41,13 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
||||
private ?ProgressBar $progressBar = null;
|
||||
|
||||
public function __construct(
|
||||
VisitServiceInterface $visitService,
|
||||
VisitLocatorInterface $visitLocator,
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
LockFactory $locker,
|
||||
GeolocationDbUpdaterInterface $dbUpdater
|
||||
) {
|
||||
parent::__construct($locker);
|
||||
$this->visitService = $visitService;
|
||||
$this->visitLocator = $visitLocator;
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
}
|
||||
@@ -54,32 +56,79 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Resolves visits origin locations.');
|
||||
->setDescription('Resolves visits origin locations.')
|
||||
->addOption(
|
||||
'retry',
|
||||
'r',
|
||||
InputOption::VALUE_NONE,
|
||||
'Will retry the location of visits that were located with a not-found location, in case it was due to '
|
||||
. 'a temporal issue.',
|
||||
)
|
||||
->addOption(
|
||||
'all',
|
||||
'a',
|
||||
InputOption::VALUE_NONE,
|
||||
'When provided together with --retry, will locate all existing visits, regardless the fact that they '
|
||||
. 'have already been located.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function initialize(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$retry = $input->getOption('retry');
|
||||
$all = $input->getOption('all');
|
||||
|
||||
if ($all && !$retry) {
|
||||
$this->io->writeln(
|
||||
'<comment>The <fg=yellow;options=bold>--all</> flag has no effect on its own. You have to provide it '
|
||||
. 'together with <fg=yellow;options=bold>--retry</>.</comment>',
|
||||
);
|
||||
}
|
||||
|
||||
if ($all && $retry && ! $this->warnAndVerifyContinue()) {
|
||||
throw new RuntimeException('Execution aborted');
|
||||
}
|
||||
}
|
||||
|
||||
private function warnAndVerifyContinue(): bool
|
||||
{
|
||||
$this->io->warning([
|
||||
'You are about to process the location of all existing visits your short URLs received.',
|
||||
'Since shlink saves visitors IP addresses anonymized, you could end up losing precision on some of '
|
||||
. 'your visits.',
|
||||
'Also, if you have a large amount of visits, this can be a very time consuming process. '
|
||||
. 'Continue at your own risk.',
|
||||
]);
|
||||
return $this->io->confirm('Do you want to proceed?', false);
|
||||
}
|
||||
|
||||
protected function lockedExecute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->io = new SymfonyStyle($input, $output);
|
||||
$retry = $input->getOption('retry');
|
||||
$all = $retry && $input->getOption('all');
|
||||
|
||||
try {
|
||||
$this->checkDbUpdate();
|
||||
|
||||
$this->visitService->locateUnlocatedVisits(
|
||||
[$this, 'getGeolocationDataForVisit'],
|
||||
static function (VisitLocation $location) use ($output): void {
|
||||
if (!$location->isEmpty()) {
|
||||
$output->writeln(
|
||||
sprintf(' [<info>Address located at "%s"</info>]', $location->getCountryName()),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if ($all) {
|
||||
$this->visitLocator->locateAllVisits($this);
|
||||
} else {
|
||||
$this->visitLocator->locateUnlocatedVisits($this);
|
||||
if ($retry) {
|
||||
$this->visitLocator->locateVisitsWithEmptyLocation($this);
|
||||
}
|
||||
}
|
||||
|
||||
$this->io->success('Finished processing all IPs');
|
||||
$this->io->success('Finished locating visits');
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->io->error($e->getMessage());
|
||||
if ($e instanceof Exception && $this->io->isVerbose()) {
|
||||
if ($e instanceof Throwable && $this->io->isVerbose()) {
|
||||
$this->getApplication()->renderThrowable($e, $this->io);
|
||||
}
|
||||
|
||||
@@ -87,7 +136,10 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
||||
}
|
||||
}
|
||||
|
||||
public function getGeolocationDataForVisit(Visit $visit): Location
|
||||
/**
|
||||
* @throws IpCannotBeLocatedException
|
||||
*/
|
||||
public function geolocateVisit(Visit $visit): Location
|
||||
{
|
||||
if (! $visit->hasRemoteAddr()) {
|
||||
$this->io->writeln(
|
||||
@@ -116,6 +168,14 @@ class LocateVisitsCommand extends AbstractLockedCommand
|
||||
}
|
||||
}
|
||||
|
||||
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void
|
||||
{
|
||||
$message = ! $visitLocation->isEmpty()
|
||||
? sprintf(' [<info>Address located in "%s"</info>]', $visitLocation->getCountryName())
|
||||
: ' [<comment>Address not found</comment>]';
|
||||
$this->io->writeln($message);
|
||||
}
|
||||
|
||||
private function checkDbUpdate(): void
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI;
|
||||
|
||||
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
|
||||
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
|
||||
|
||||
class ConfigProvider
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class CreateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
@@ -114,7 +115,8 @@ class CreateDatabaseCommandTest extends TestCase
|
||||
'/usr/local/bin/php',
|
||||
CreateDatabaseCommand::DOCTRINE_SCRIPT,
|
||||
CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
|
||||
], Argument::cetera());
|
||||
'--no-interaction',
|
||||
], Argument::cetera())->willReturn(new Process([]));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@@ -15,6 +15,7 @@ use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Lock\LockFactory;
|
||||
use Symfony\Component\Lock\LockInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class MigrateDatabaseCommandTest extends TestCase
|
||||
{
|
||||
@@ -53,7 +54,8 @@ class MigrateDatabaseCommandTest extends TestCase
|
||||
'/usr/local/bin/php',
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
|
||||
MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
|
||||
], Argument::cetera());
|
||||
'--no-interaction',
|
||||
], Argument::cetera())->willReturn(new Process([]));
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
@@ -31,7 +31,7 @@ class GenerateShortUrlCommandTest extends TestCase
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG);
|
||||
$command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5);
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
$this->commandTester = new CommandTester($command);
|
||||
|
||||
@@ -15,18 +15,21 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Service\VisitService;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitGeolocationHelperInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitLocator;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Lock;
|
||||
|
||||
use function array_shift;
|
||||
use function sprintf;
|
||||
|
||||
use const PHP_EOL;
|
||||
|
||||
class LocateVisitsCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
@@ -38,7 +41,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->visitService = $this->prophesize(VisitService::class);
|
||||
$this->visitService = $this->prophesize(VisitLocator::class);
|
||||
$this->ipResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
|
||||
@@ -61,31 +64,53 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$this->commandTester = new CommandTester($command);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function allPendingVisitsAreProcessed(): void
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideArgs
|
||||
*/
|
||||
public function expectedSetOfVisitsIsProcessedBasedOnArgs(
|
||||
int $expectedUnlocatedCalls,
|
||||
int $expectedEmptyCalls,
|
||||
int $expectedAllCalls,
|
||||
bool $expectWarningPrint,
|
||||
array $args
|
||||
): void {
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
function (array $args) use ($visit, $location): void {
|
||||
$firstCallback = array_shift($args);
|
||||
$firstCallback($visit);
|
||||
|
||||
$secondCallback = array_shift($args);
|
||||
$secondCallback($location, $visit);
|
||||
},
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||
$locateEmptyVisits = $this->visitService->locateVisitsWithEmptyLocation(Argument::cetera())->will(
|
||||
$mockMethodBehavior,
|
||||
);
|
||||
$locateAllVisits = $this->visitService->locateAllVisits(Argument::cetera())->will($mockMethodBehavior);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||
Location::emptyInstance(),
|
||||
);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$this->commandTester->setInputs(['y']);
|
||||
$this->commandTester->execute($args);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('Processing IP 1.2.3.0', $output);
|
||||
$locateVisits->shouldHaveBeenCalledOnce();
|
||||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
||||
if ($expectWarningPrint) {
|
||||
$this->assertStringContainsString('Continue at your own risk', $output);
|
||||
} else {
|
||||
$this->assertStringNotContainsString('Continue at your own risk', $output);
|
||||
}
|
||||
$locateVisits->shouldHaveBeenCalledTimes($expectedUnlocatedCalls);
|
||||
$locateEmptyVisits->shouldHaveBeenCalledTimes($expectedEmptyCalls);
|
||||
$locateAllVisits->shouldHaveBeenCalledTimes($expectedAllCalls);
|
||||
$resolveIpLocation->shouldHaveBeenCalledTimes(
|
||||
$expectedUnlocatedCalls + $expectedEmptyCalls + $expectedAllCalls,
|
||||
);
|
||||
}
|
||||
|
||||
public function provideArgs(): iterable
|
||||
{
|
||||
yield 'no args' => [1, 0, 0, false, []];
|
||||
yield 'retry' => [1, 1, 0, false, ['--retry' => true]];
|
||||
yield 'all' => [0, 0, 1, true, ['--retry' => true, '--all' => true]];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,13 +123,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
function (array $args) use ($visit, $location): void {
|
||||
$firstCallback = array_shift($args);
|
||||
$firstCallback($visit);
|
||||
|
||||
$secondCallback = array_shift($args);
|
||||
$secondCallback($location, $visit);
|
||||
},
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn(
|
||||
Location::emptyInstance(),
|
||||
@@ -137,13 +156,7 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
|
||||
$locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
|
||||
function (array $args) use ($visit, $location): void {
|
||||
$firstCallback = array_shift($args);
|
||||
$firstCallback($visit);
|
||||
|
||||
$secondCallback = array_shift($args);
|
||||
$secondCallback($location, $visit);
|
||||
},
|
||||
$this->invokeHelperMethods($visit, $location),
|
||||
);
|
||||
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willThrow(WrongIpException::class);
|
||||
|
||||
@@ -156,6 +169,17 @@ class LocateVisitsCommandTest extends TestCase
|
||||
$resolveIpLocation->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
private function invokeHelperMethods(Visit $visit, VisitLocation $location): callable
|
||||
{
|
||||
return function (array $args) use ($visit, $location): void {
|
||||
/** @var VisitGeolocationHelperInterface $helper */
|
||||
[$helper] = $args;
|
||||
|
||||
$helper->geolocateVisit($visit);
|
||||
$helper->onVisitLocated($location, $visit);
|
||||
};
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noActionIsPerformedIfLockIsAcquired(): void
|
||||
{
|
||||
@@ -212,4 +236,33 @@ class LocateVisitsCommandTest extends TestCase
|
||||
yield [true, '[Warning] GeoLite2 database update failed. Proceeding with old version.'];
|
||||
yield [false, 'GeoLite2 database download failed. It is not possible to locate visits.'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function providingAllFlagOnItsOwnDisplaysNotice(): void
|
||||
{
|
||||
$this->commandTester->execute(['--all' => true]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('The --all flag has no effect on its own', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAbortInputs
|
||||
*/
|
||||
public function processingAllCancelsCommandIfUserDoesNotActivelyAgreeToConfirmation(array $inputs): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Execution aborted');
|
||||
|
||||
$this->commandTester->setInputs($inputs);
|
||||
$this->commandTester->execute(['--all' => true, '--retry' => true]);
|
||||
}
|
||||
|
||||
public function provideAbortInputs(): iterable
|
||||
{
|
||||
yield 'n' => [['n']];
|
||||
yield 'no' => [['no']];
|
||||
yield 'default' => [[PHP_EOL]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Mezzio\Router\RouterInterface;
|
||||
use Mezzio\Template\TemplateRendererInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler;
|
||||
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
|
||||
|
||||
@@ -27,7 +26,7 @@ return [
|
||||
Service\UrlShortener::class => ConfigAbstractFactory::class,
|
||||
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\VisitService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
@@ -38,7 +37,9 @@ return [
|
||||
Action\PixelAction::class => ConfigAbstractFactory::class,
|
||||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
|
||||
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -51,10 +52,10 @@ return [
|
||||
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
|
||||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Options\UrlShortenerOptions::class],
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
|
||||
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class],
|
||||
Service\VisitService::class => ['em'],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
|
||||
Visit\VisitLocator::class => ['em'],
|
||||
Service\Tag\TagService::class => ['em'],
|
||||
Service\ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
@@ -63,7 +64,7 @@ return [
|
||||
],
|
||||
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient'],
|
||||
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||
|
||||
Action\RedirectAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
@@ -78,12 +79,14 @@ return [
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\QrCodeAction::class => [
|
||||
RouterInterface::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
|
||||
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
|
||||
Resolver\PersistenceDomainResolver::class => ['em'],
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -44,4 +44,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
->columnName('lon')
|
||||
->nullable(false)
|
||||
->build();
|
||||
|
||||
$builder->createField('isEmpty', Types::BOOLEAN)
|
||||
->columnName('is_empty')
|
||||
->option('default', false)
|
||||
->nullable(false)
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Symfony\Component\Mercure\Publisher;
|
||||
|
||||
return [
|
||||
|
||||
'events' => [
|
||||
'regular' => [
|
||||
EventDispatcher\VisitLocated::class => [
|
||||
EventDispatcher\NotifyVisitToMercure::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class,
|
||||
],
|
||||
],
|
||||
@@ -28,6 +30,13 @@ return [
|
||||
'factories' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'delegators' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -47,6 +56,12 @@ return [
|
||||
'config.url_shortener.domain',
|
||||
Options\AppOptions::class,
|
||||
],
|
||||
EventDispatcher\NotifyVisitToMercure::class => [
|
||||
Publisher::class,
|
||||
Mercure\MercureUpdatesGenerator::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
|
||||
use RKA\Middleware\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Action;
|
||||
use Shlinkio\Shlink\Core\Middleware;
|
||||
|
||||
return [
|
||||
|
||||
@@ -32,7 +31,6 @@ return [
|
||||
'name' => Action\QrCodeAction::class,
|
||||
'path' => '/{shortCode}/qr-code[/{size:[0-9]+}]',
|
||||
'middleware' => [
|
||||
Middleware\QrCodeCacheMiddleware::class,
|
||||
Action\QrCodeAction::class,
|
||||
],
|
||||
'allowed_methods' => [RequestMethod::METHOD_GET],
|
||||
|
||||
@@ -10,7 +10,11 @@ use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
function generateRandomShortCode(int $length = 5): string
|
||||
const DEFAULT_SHORT_CODES_LENGTH = 5;
|
||||
const MIN_SHORT_CODES_LENGTH = 4;
|
||||
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
|
||||
|
||||
function generateRandomShortCode(int $length): string
|
||||
{
|
||||
static $shortIdFactory;
|
||||
if ($shortIdFactory === null) {
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
@@ -24,7 +26,7 @@ use function array_merge;
|
||||
use function GuzzleHttp\Psr7\build_query;
|
||||
use function GuzzleHttp\Psr7\parse_query;
|
||||
|
||||
abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
|
||||
{
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private VisitsTrackerInterface $visitTracker;
|
||||
@@ -50,14 +52,13 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||
|
||||
try {
|
||||
$url = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
|
||||
// Track visit to this short code
|
||||
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {
|
||||
$this->visitTracker->track($url, Visitor::fromRequest($request));
|
||||
if ($this->shouldTrackRequest($request, $query, $disableTrackParam)) {
|
||||
$this->visitTracker->track($shortUrl, Visitor::fromRequest($request));
|
||||
}
|
||||
|
||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
|
||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($shortUrl, $query, $disableTrackParam));
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
|
||||
return $this->createErrorResp($request, $handler);
|
||||
@@ -76,6 +77,16 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
return (string) $uri->withQuery(build_query($mergedQuery));
|
||||
}
|
||||
|
||||
private function shouldTrackRequest(ServerRequestInterface $request, array $query, ?string $disableTrackParam): bool
|
||||
{
|
||||
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
|
||||
if ($forwardedMethod === self::METHOD_HEAD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
|
||||
}
|
||||
|
||||
abstract protected function createSuccessResp(string $longUrl): ResponseInterface;
|
||||
|
||||
abstract protected function createErrorResp(
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Mezzio\Router\RouterInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
@@ -23,17 +22,17 @@ class QrCodeAction implements MiddlewareInterface
|
||||
private const MIN_SIZE = 50;
|
||||
private const MAX_SIZE = 1000;
|
||||
|
||||
private RouterInterface $router;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private array $domainConfig;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
RouterInterface $router,
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
$this->router = $router;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->domainConfig = $domainConfig;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
}
|
||||
|
||||
@@ -42,23 +41,19 @@ class QrCodeAction implements MiddlewareInterface
|
||||
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||
|
||||
try {
|
||||
$this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $identifier->shortCode()]);
|
||||
$size = $this->getSizeParam($request);
|
||||
|
||||
$qrCode = new QrCode((string) $request->getUri()->withPath($path)->withQuery(''));
|
||||
$qrCode->setSize($size);
|
||||
$qrCode = new QrCode($shortUrl->toString($this->domainConfig));
|
||||
$qrCode->setSize($this->getSizeParam($request));
|
||||
$qrCode->setMargin(0);
|
||||
|
||||
return new QrCodeResponse($qrCode);
|
||||
}
|
||||
|
||||
/**
|
||||
*/
|
||||
private function getSizeParam(Request $request): int
|
||||
{
|
||||
$size = (int) $request->getAttribute('size', self::DEFAULT_SIZE);
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Config;
|
||||
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use Shlinkio\Shlink\Installer\Util\PathCollection;
|
||||
use Shlinkio\Shlink\Config\Collection\PathCollection;
|
||||
|
||||
use function array_flip;
|
||||
use function array_intersect_key;
|
||||
@@ -24,7 +24,7 @@ class SimplifiedConfigParser
|
||||
'validate_url' => ['url_shortener', 'validate_url'],
|
||||
'invalid_short_url_redirect_to' => ['not_found_redirects', 'invalid_short_url'],
|
||||
'regular_404_redirect_to' => ['not_found_redirects', 'regular_404'],
|
||||
'base_url_redirect_to' => ['not_found_redirects', 'base_path'],
|
||||
'base_url_redirect_to' => ['not_found_redirects', 'base_url'],
|
||||
'db_config' => ['entity_manager', 'connection'],
|
||||
'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
|
||||
'redis_servers' => ['cache', 'redis', 'servers'],
|
||||
@@ -32,6 +32,10 @@ class SimplifiedConfigParser
|
||||
'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'],
|
||||
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
|
||||
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
||||
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
|
||||
'mercure_public_hub_url' => ['mercure', 'public_hub_url'],
|
||||
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
|
||||
'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'delete_short_url_threshold' => [
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use function Shlinkio\Shlink\Common\loadConfigFromGlob;
|
||||
use function Shlinkio\Shlink\Config\loadConfigFromGlob;
|
||||
|
||||
class ConfigProvider
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\SimpleDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
|
||||
use function array_reduce;
|
||||
@@ -32,8 +33,9 @@ class ShortUrl extends AbstractEntity
|
||||
private ?Chronos $validSince = null;
|
||||
private ?Chronos $validUntil = null;
|
||||
private ?int $maxVisits = null;
|
||||
private ?Domain $domain;
|
||||
private ?Domain $domain = null;
|
||||
private bool $customSlugWasProvided;
|
||||
private int $shortCodeLength;
|
||||
|
||||
public function __construct(
|
||||
string $longUrl,
|
||||
@@ -50,7 +52,8 @@ class ShortUrl extends AbstractEntity
|
||||
$this->validUntil = $meta->getValidUntil();
|
||||
$this->maxVisits = $meta->getMaxVisits();
|
||||
$this->customSlugWasProvided = $meta->hasCustomSlug();
|
||||
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode();
|
||||
$this->shortCodeLength = $meta->getShortCodeLength();
|
||||
$this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
|
||||
$this->domain = ($domainResolver ?? new SimpleDomainResolver())->resolveDomain($meta->getDomain());
|
||||
}
|
||||
|
||||
@@ -91,16 +94,19 @@ class ShortUrl extends AbstractEntity
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function updateMeta(ShortUrlMeta $shortCodeMeta): void
|
||||
public function update(ShortUrlEdit $shortUrlEdit): void
|
||||
{
|
||||
if ($shortCodeMeta->hasValidSince()) {
|
||||
$this->validSince = $shortCodeMeta->getValidSince();
|
||||
if ($shortUrlEdit->hasValidSince()) {
|
||||
$this->validSince = $shortUrlEdit->validSince();
|
||||
}
|
||||
if ($shortCodeMeta->hasValidUntil()) {
|
||||
$this->validUntil = $shortCodeMeta->getValidUntil();
|
||||
if ($shortUrlEdit->hasValidUntil()) {
|
||||
$this->validUntil = $shortUrlEdit->validUntil();
|
||||
}
|
||||
if ($shortCodeMeta->hasMaxVisits()) {
|
||||
$this->maxVisits = $shortCodeMeta->getMaxVisits();
|
||||
if ($shortUrlEdit->hasMaxVisits()) {
|
||||
$this->maxVisits = $shortUrlEdit->maxVisits();
|
||||
}
|
||||
if ($shortUrlEdit->hasLongUrl()) {
|
||||
$this->longUrl = $shortUrlEdit->longUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +125,7 @@ class ShortUrl extends AbstractEntity
|
||||
throw ShortCodeCannotBeRegeneratedException::forShortUrlAlreadyPersisted();
|
||||
}
|
||||
|
||||
$this->shortCode = generateRandomShortCode();
|
||||
$this->shortCode = generateRandomShortCode($this->shortCodeLength);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
|
||||
|
||||
class Visit extends AbstractEntity implements JsonSerializable
|
||||
@@ -60,9 +59,9 @@ class Visit extends AbstractEntity implements JsonSerializable
|
||||
return $this->shortUrl;
|
||||
}
|
||||
|
||||
public function getVisitLocation(): VisitLocationInterface
|
||||
public function getVisitLocation(): ?VisitLocationInterface
|
||||
{
|
||||
return $this->visitLocation ?? new UnknownVisitLocation();
|
||||
return $this->visitLocation;
|
||||
}
|
||||
|
||||
public function isLocatable(): bool
|
||||
|
||||
@@ -17,6 +17,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||
private float $latitude;
|
||||
private float $longitude;
|
||||
private string $timezone;
|
||||
private bool $isEmpty;
|
||||
|
||||
public function __construct(Location $location)
|
||||
{
|
||||
@@ -43,6 +44,11 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||
return $this->cityName;
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->isEmpty;
|
||||
}
|
||||
|
||||
private function exchangeLocationInfo(Location $info): void
|
||||
{
|
||||
$this->countryCode = $info->countryCode();
|
||||
@@ -52,6 +58,15 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||
$this->latitude = $info->latitude();
|
||||
$this->longitude = $info->longitude();
|
||||
$this->timezone = $info->timeZone();
|
||||
$this->isEmpty = (
|
||||
$this->countryCode === '' &&
|
||||
$this->countryName === '' &&
|
||||
$this->regionName === '' &&
|
||||
$this->cityName === '' &&
|
||||
$this->latitude === 0.0 &&
|
||||
$this->longitude === 0.0 &&
|
||||
$this->timezone === ''
|
||||
);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
@@ -64,18 +79,7 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface
|
||||
'latitude' => $this->latitude,
|
||||
'longitude' => $this->longitude,
|
||||
'timezone' => $this->timezone,
|
||||
'isEmpty' => $this->isEmpty,
|
||||
];
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return
|
||||
$this->countryCode === '' &&
|
||||
$this->countryName === '' &&
|
||||
$this->regionName === '' &&
|
||||
$this->cityName === '' &&
|
||||
$this->latitude === 0.0 &&
|
||||
$this->longitude === 0.0 &&
|
||||
$this->timezone === '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
|
||||
class CloseDbConnectionEventListener
|
||||
{
|
||||
private ReopeningEntityManagerInterface $em;
|
||||
/** @var callable */
|
||||
private $wrapped;
|
||||
|
||||
public function __construct(ReopeningEntityManagerInterface $em, callable $wrapped)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->wrapped = $wrapped;
|
||||
}
|
||||
|
||||
public function __invoke(object $event): void
|
||||
{
|
||||
$this->em->open();
|
||||
|
||||
try {
|
||||
($this->wrapped)($event);
|
||||
} finally {
|
||||
$this->em->getConnection()->close();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
|
||||
class CloseDbConnectionEventListenerDelegator
|
||||
{
|
||||
public function __invoke(
|
||||
ContainerInterface $container,
|
||||
string $name,
|
||||
callable $callback
|
||||
): CloseDbConnectionEventListener {
|
||||
/** @var callable $wrapped */
|
||||
$wrapped = $callback();
|
||||
/** @var ReopeningEntityManagerInterface $em */
|
||||
$em = $container->get('em');
|
||||
|
||||
return new CloseDbConnectionEventListener($em, $wrapped);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class LocateShortUrlVisit
|
||||
}
|
||||
|
||||
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
|
||||
$this->locateVisit($visitId, $visit);
|
||||
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
||||
@@ -80,12 +80,13 @@ class LocateShortUrlVisit
|
||||
return true;
|
||||
}
|
||||
|
||||
private function locateVisit(string $visitId, Visit $visit): void
|
||||
private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void
|
||||
{
|
||||
$isLocatable = $originalIpAddress !== null || $visit->isLocatable();
|
||||
$addr = $originalIpAddress ?? $visit->getRemoteAddr();
|
||||
|
||||
try {
|
||||
$location = $visit->isLocatable()
|
||||
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
|
||||
: Location::emptyInstance();
|
||||
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();
|
||||
|
||||
$visit->locate(new VisitLocation($location));
|
||||
$this->em->flush();
|
||||
|
||||
55
module/Core/src/EventDispatcher/NotifyVisitToMercure.php
Normal file
55
module/Core/src/EventDispatcher/NotifyVisitToMercure.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
use Throwable;
|
||||
|
||||
class NotifyVisitToMercure
|
||||
{
|
||||
private PublisherInterface $publisher;
|
||||
private MercureUpdatesGeneratorInterface $updatesGenerator;
|
||||
private EntityManagerInterface $em;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
PublisherInterface $publisher,
|
||||
MercureUpdatesGeneratorInterface $updatesGenerator,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->publisher = $publisher;
|
||||
$this->em = $em;
|
||||
$this->logger = $logger;
|
||||
$this->updatesGenerator = $updatesGenerator;
|
||||
}
|
||||
|
||||
public function __invoke(VisitLocated $shortUrlLocated): void
|
||||
{
|
||||
$visitId = $shortUrlLocated->visitId();
|
||||
|
||||
/** @var Visit|null $visit */
|
||||
$visit = $this->em->find(Visit::class, $visitId);
|
||||
if ($visit === null) {
|
||||
$this->logger->warning('Tried to notify mercure for visit with id "{visitId}", but it does not exist.', [
|
||||
'visitId' => $visitId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
($this->publisher)($this->updatesGenerator->newShortUrlVisitUpdate($visit));
|
||||
($this->publisher)($this->updatesGenerator->newVisitUpdate($visit));
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [
|
||||
'e' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Promise\Promise;
|
||||
use GuzzleHttp\Promise\PromiseInterface;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
@@ -89,12 +90,14 @@ class NotifyVisitToWebHooks
|
||||
*/
|
||||
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),
|
||||
);
|
||||
});
|
||||
$logWebhookFailure = Closure::fromCallable([$this, 'logWebhookFailure']);
|
||||
|
||||
return map(
|
||||
$this->webhooks,
|
||||
fn (string $webhook): PromiseInterface => $this->httpClient
|
||||
->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions)
|
||||
->otherwise(partial_left($logWebhookFailure, $webhook, $visitId)),
|
||||
);
|
||||
}
|
||||
|
||||
private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void
|
||||
|
||||
@@ -9,10 +9,12 @@ use JsonSerializable;
|
||||
final class ShortUrlVisited implements JsonSerializable
|
||||
{
|
||||
private string $visitId;
|
||||
private ?string $originalIpAddress;
|
||||
|
||||
public function __construct(string $visitId)
|
||||
public function __construct(string $visitId, ?string $originalIpAddress = null)
|
||||
{
|
||||
$this->visitId = $visitId;
|
||||
$this->originalIpAddress = $originalIpAddress;
|
||||
}
|
||||
|
||||
public function visitId(): string
|
||||
@@ -20,8 +22,13 @@ final class ShortUrlVisited implements JsonSerializable
|
||||
return $this->visitId;
|
||||
}
|
||||
|
||||
public function originalIpAddress(): ?string
|
||||
{
|
||||
return $this->originalIpAddress;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return ['visitId' => $this->visitId];
|
||||
return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress];
|
||||
}
|
||||
}
|
||||
|
||||
50
module/Core/src/Mercure/MercureUpdatesGenerator.php
Normal file
50
module/Core/src/Mercure/MercureUpdatesGenerator.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Mercure;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
use function json_encode;
|
||||
use function sprintf;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
|
||||
{
|
||||
private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
|
||||
|
||||
private ShortUrlDataTransformer $transformer;
|
||||
|
||||
public function __construct(array $domainConfig)
|
||||
{
|
||||
$this->transformer = new ShortUrlDataTransformer($domainConfig);
|
||||
}
|
||||
|
||||
public function newVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
return new Update(self::NEW_VISIT_TOPIC, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
$shortUrl = $visit->getShortUrl();
|
||||
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode());
|
||||
|
||||
return new Update($topic, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
||||
private function serialize(array $data): string
|
||||
{
|
||||
return json_encode($data, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
15
module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php
Normal file
15
module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Mercure;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
interface MercureUpdatesGeneratorInterface
|
||||
{
|
||||
public function newVisitUpdate(Visit $visit): Update;
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Middleware;
|
||||
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Laminas\Diactoros\Response as DiactResp;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class QrCodeCacheMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private Cache $cache;
|
||||
|
||||
public function __construct(Cache $cache)
|
||||
{
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming server request and return a response, optionally delegating
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
*
|
||||
*/
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
$cacheKey = $request->getUri()->getPath();
|
||||
|
||||
// If this QR code is already cached, just return it
|
||||
if ($this->cache->contains($cacheKey)) {
|
||||
$qrData = $this->cache->fetch($cacheKey);
|
||||
$response = new DiactResp();
|
||||
$response->getBody()->write($qrData['body']);
|
||||
return $response->withHeader('Content-Type', $qrData['content-type']);
|
||||
}
|
||||
|
||||
// If not, call the next middleware and cache it
|
||||
/** @var Response $resp */
|
||||
$resp = $handler->handle($request);
|
||||
$this->cache->save($cacheKey, [
|
||||
'body' => $resp->getBody()->__toString(),
|
||||
'content-type' => $resp->getHeaderLine('Content-Type'),
|
||||
]);
|
||||
return $resp;
|
||||
}
|
||||
}
|
||||
106
module/Core/src/Model/ShortUrlEdit.php
Normal file
106
module/Core/src/Model/ShortUrlEdit.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function array_key_exists;
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
final class ShortUrlEdit
|
||||
{
|
||||
private bool $longUrlPropWasProvided = false;
|
||||
private ?string $longUrl = null;
|
||||
private bool $validSincePropWasProvided = false;
|
||||
private ?Chronos $validSince = null;
|
||||
private bool $validUntilPropWasProvided = false;
|
||||
private ?Chronos $validUntil = null;
|
||||
private bool $maxVisitsPropWasProvided = false;
|
||||
private ?int $maxVisits = null;
|
||||
|
||||
// Enforce named constructors
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $data): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validateAndInit($data);
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
$inputFilter = new ShortUrlMetaInputFilter($data);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
$this->longUrlPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::LONG_URL, $data);
|
||||
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
|
||||
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
|
||||
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
|
||||
|
||||
$this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL);
|
||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
}
|
||||
|
||||
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
public function longUrl(): ?string
|
||||
{
|
||||
return $this->longUrl;
|
||||
}
|
||||
|
||||
public function hasLongUrl(): bool
|
||||
{
|
||||
return $this->longUrlPropWasProvided && $this->longUrl !== null;
|
||||
}
|
||||
|
||||
public function validSince(): ?Chronos
|
||||
{
|
||||
return $this->validSince;
|
||||
}
|
||||
|
||||
public function hasValidSince(): bool
|
||||
{
|
||||
return $this->validSincePropWasProvided;
|
||||
}
|
||||
|
||||
public function validUntil(): ?Chronos
|
||||
{
|
||||
return $this->validUntil;
|
||||
}
|
||||
|
||||
public function hasValidUntil(): bool
|
||||
{
|
||||
return $this->validUntilPropWasProvided;
|
||||
}
|
||||
|
||||
public function maxVisits(): ?int
|
||||
{
|
||||
return $this->maxVisits;
|
||||
}
|
||||
|
||||
public function hasMaxVisits(): bool
|
||||
{
|
||||
return $this->maxVisitsPropWasProvided;
|
||||
}
|
||||
}
|
||||
@@ -8,22 +8,21 @@ use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function array_key_exists;
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
final class ShortUrlMeta
|
||||
{
|
||||
private bool $validSincePropWasProvided = false;
|
||||
private ?Chronos $validSince = null;
|
||||
private bool $validUntilPropWasProvided = false;
|
||||
private ?Chronos $validUntil = null;
|
||||
private ?string $customSlug = null;
|
||||
private bool $maxVisitsPropWasProvided = false;
|
||||
private ?int $maxVisits = null;
|
||||
private ?bool $findIfExists = null;
|
||||
private ?string $domain = null;
|
||||
private int $shortCodeLength = 5;
|
||||
|
||||
// Force named constructors
|
||||
// Enforce named constructors
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
@@ -54,15 +53,21 @@ final class ShortUrlMeta
|
||||
}
|
||||
|
||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
|
||||
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
|
||||
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
|
||||
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null;
|
||||
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
|
||||
$this->maxVisits = $this->getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
|
||||
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
|
||||
$this->shortCodeLength = $this->getOptionalIntFromInputFilter(
|
||||
$inputFilter,
|
||||
ShortUrlMetaInputFilter::SHORT_CODE_LENGTH,
|
||||
) ?? DEFAULT_SHORT_CODES_LENGTH;
|
||||
}
|
||||
|
||||
private function getOptionalIntFromInputFilter(ShortUrlMetaInputFilter $inputFilter, string $fieldName): ?int
|
||||
{
|
||||
$value = $inputFilter->getValue($fieldName);
|
||||
return $value !== null ? (int) $value : null;
|
||||
}
|
||||
|
||||
public function getValidSince(): ?Chronos
|
||||
@@ -72,7 +77,7 @@ final class ShortUrlMeta
|
||||
|
||||
public function hasValidSince(): bool
|
||||
{
|
||||
return $this->validSincePropWasProvided;
|
||||
return $this->validSince !== null;
|
||||
}
|
||||
|
||||
public function getValidUntil(): ?Chronos
|
||||
@@ -82,7 +87,7 @@ final class ShortUrlMeta
|
||||
|
||||
public function hasValidUntil(): bool
|
||||
{
|
||||
return $this->validUntilPropWasProvided;
|
||||
return $this->validUntil !== null;
|
||||
}
|
||||
|
||||
public function getCustomSlug(): ?string
|
||||
@@ -102,7 +107,7 @@ final class ShortUrlMeta
|
||||
|
||||
public function hasMaxVisits(): bool
|
||||
{
|
||||
return $this->maxVisitsPropWasProvided;
|
||||
return $this->maxVisits !== null;
|
||||
}
|
||||
|
||||
public function findIfExists(): bool
|
||||
@@ -119,4 +124,9 @@ final class ShortUrlMeta
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function getShortCodeLength(): int
|
||||
{
|
||||
return $this->shortCodeLength;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,11 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Options;
|
||||
|
||||
use Laminas\Stdlib\AbstractOptions;
|
||||
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class AppOptions extends AbstractOptions
|
||||
{
|
||||
use StringUtilsTrait;
|
||||
|
||||
private string $name = '';
|
||||
private string $version = '1.0';
|
||||
private ?string $disableTrackParam = null;
|
||||
|
||||
@@ -12,33 +12,63 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in
|
||||
* smaller blocks of a specific size.
|
||||
* This will have side effects if you update those rows while you iterate them.
|
||||
* If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the
|
||||
* dataset
|
||||
*
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
{
|
||||
$dql = <<<DQL
|
||||
SELECT v FROM Shlinkio\Shlink\Core\Entity\Visit AS v WHERE v.visitLocation IS NULL
|
||||
DQL;
|
||||
$query = $this->getEntityManager()->createQuery($dql)
|
||||
->setMaxResults($blockSize);
|
||||
$remainingVisitsToProcess = $this->count(['visitLocation' => null]);
|
||||
$offset = 0;
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v')
|
||||
->where($qb->expr()->isNull('v.visitLocation'));
|
||||
|
||||
while ($remainingVisitsToProcess > 0) {
|
||||
$iterator = $query->setFirstResult($applyOffset ? $offset : null)->iterate();
|
||||
foreach ($iterator as $key => [$value]) {
|
||||
yield $key => $value;
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v')
|
||||
->join('v.visitLocation', 'vl')
|
||||
->where($qb->expr()->isNotNull('v.visitLocation'))
|
||||
->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
|
||||
->setParameter('isEmpty', true);
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('v')
|
||||
->from(Visit::class, 'v');
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable
|
||||
{
|
||||
$originalQueryBuilder = $qb->setMaxResults($blockSize)
|
||||
->orderBy('v.id', 'ASC');
|
||||
$lastId = '0';
|
||||
|
||||
do {
|
||||
$qb = (clone $originalQueryBuilder)->andWhere($qb->expr()->gt('v.id', $lastId));
|
||||
$iterator = $qb->getQuery()->iterate();
|
||||
$resultsFound = false;
|
||||
|
||||
/** @var Visit $visit */
|
||||
foreach ($iterator as $key => [$visit]) {
|
||||
$resultsFound = true;
|
||||
yield $key => $visit;
|
||||
}
|
||||
|
||||
$remainingVisitsToProcess -= $blockSize;
|
||||
$offset += $blockSize;
|
||||
}
|
||||
// As the query is ordered by ID, we can take the last one every time in order to exclude the whole list
|
||||
$lastId = isset($visit) ? $visit->getId() : $lastId;
|
||||
} while ($resultsFound);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,15 +13,19 @@ interface VisitRepositoryInterface extends ObjectRepository
|
||||
public const DEFAULT_BLOCK_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* This method will allow you to iterate the whole list of unlocated visits, but loading them into memory in
|
||||
* smaller blocks of a specific size.
|
||||
* This will have side effects if you update those rows while you iterate them.
|
||||
* If you plan to do so, pass the first argument as false in order to disable applying offsets while slicing the
|
||||
* dataset
|
||||
*
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findUnlocatedVisits(bool $applyOffset = true, int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
public function findUnlocatedVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
|
||||
/**
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findVisitsWithEmptyLocation(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
|
||||
/**
|
||||
* @return iterable|Visit[]
|
||||
*/
|
||||
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable;
|
||||
|
||||
/**
|
||||
* @return Visit[]
|
||||
|
||||
@@ -7,14 +7,16 @@ namespace Shlinkio\Shlink\Core\Service;
|
||||
use Doctrine\ORM;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
||||
class ShortUrlService implements ShortUrlServiceInterface
|
||||
{
|
||||
@@ -22,11 +24,16 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
|
||||
private ORM\EntityManagerInterface $em;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private UrlValidatorInterface $urlValidator;
|
||||
|
||||
public function __construct(ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver)
|
||||
{
|
||||
public function __construct(
|
||||
ORM\EntityManagerInterface $em,
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
UrlValidatorInterface $urlValidator
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->urlValidator = $urlValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,11 +66,16 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl
|
||||
{
|
||||
if ($shortUrlEdit->hasLongUrl()) {
|
||||
$this->urlValidator->validateUrl($shortUrlEdit->longUrl());
|
||||
}
|
||||
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
$shortUrl->updateMeta($shortUrlMeta);
|
||||
$shortUrl->update($shortUrlEdit);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
|
||||
interface ShortUrlServiceInterface
|
||||
@@ -26,6 +27,7 @@ interface ShortUrlServiceInterface
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl;
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlEdit $shortUrlEdit): ShortUrl;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Domain\Resolver\DomainResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
@@ -24,17 +23,17 @@ class UrlShortener implements UrlShortenerInterface
|
||||
use TagManagerTrait;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private UrlShortenerOptions $options;
|
||||
private UrlValidatorInterface $urlValidator;
|
||||
private DomainResolverInterface $domainResolver;
|
||||
|
||||
public function __construct(
|
||||
UrlValidatorInterface $urlValidator,
|
||||
EntityManagerInterface $em,
|
||||
UrlShortenerOptions $options
|
||||
DomainResolverInterface $domainResolver
|
||||
) {
|
||||
$this->urlValidator = $urlValidator;
|
||||
$this->em = $em;
|
||||
$this->options = $options;
|
||||
$this->domainResolver = $domainResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,13 +52,9 @@ class UrlShortener implements UrlShortenerInterface
|
||||
return $existingShortUrl;
|
||||
}
|
||||
|
||||
// If the URL validation is enabled, check that the URL actually exists
|
||||
if ($this->options->isUrlValidationEnabled()) {
|
||||
$this->urlValidator->validateUrl($url);
|
||||
}
|
||||
|
||||
$this->urlValidator->validateUrl($url);
|
||||
$this->em->beginTransaction();
|
||||
$shortUrl = new ShortUrl($url, $meta, new PersistenceDomainResolver($this->em));
|
||||
$shortUrl = new ShortUrl($url, $meta, $this->domainResolver);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
class VisitService implements VisitServiceInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
public function locateUnlocatedVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void
|
||||
{
|
||||
/** @var VisitRepository $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$results = $repo->findUnlocatedVisits(false);
|
||||
$count = 0;
|
||||
$persistBlock = 200;
|
||||
|
||||
foreach ($results as $visit) {
|
||||
$count++;
|
||||
|
||||
try {
|
||||
/** @var Location $location */
|
||||
$location = $geolocateVisit($visit);
|
||||
} catch (IpCannotBeLocatedException $e) {
|
||||
if (! $e->isNonLocatableAddress()) {
|
||||
// Skip if the visit's IP could not be located because of an error
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again
|
||||
$location = Location::emptyInstance();
|
||||
}
|
||||
|
||||
$location = new VisitLocation($location);
|
||||
$this->locateVisit($visit, $location, $notifyVisitWithLocation);
|
||||
|
||||
// Flush and clear after X iterations
|
||||
if ($count % $persistBlock === 0) {
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
|
||||
private function locateVisit(Visit $visit, VisitLocation $location, ?callable $notifyVisitWithLocation): void
|
||||
{
|
||||
$visit->locate($location);
|
||||
$this->em->persist($visit);
|
||||
|
||||
if ($notifyVisitWithLocation !== null) {
|
||||
$notifyVisitWithLocation($location, $visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
interface VisitServiceInterface
|
||||
{
|
||||
public function locateUnlocatedVisits(callable $geolocateVisit, ?callable $notifyVisitWithLocation = null): void;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
$this->em->persist($visit);
|
||||
$this->em->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId()));
|
||||
$this->eventDispatcher->dispatch(new ShortUrlVisited($visit->getId(), $visitor->getRemoteAddress()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,16 +9,19 @@ use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
|
||||
class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||
{
|
||||
private const MAX_REDIRECTS = 15;
|
||||
|
||||
private ClientInterface $httpClient;
|
||||
private UrlShortenerOptions $options;
|
||||
|
||||
public function __construct(ClientInterface $httpClient)
|
||||
public function __construct(ClientInterface $httpClient, UrlShortenerOptions $options)
|
||||
{
|
||||
$this->httpClient = $httpClient;
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +29,11 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface
|
||||
*/
|
||||
public function validateUrl(string $url): void
|
||||
{
|
||||
// If the URL validation is not enabled, skip check
|
||||
if (! $this->options->isUrlValidationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->httpClient->request(self::METHOD_GET, $url, [
|
||||
RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS],
|
||||
|
||||
@@ -5,10 +5,13 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Validation;
|
||||
|
||||
use DateTime;
|
||||
use Laminas\InputFilter\Input;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
|
||||
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
|
||||
|
||||
class ShortUrlMetaInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
@@ -19,6 +22,8 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
public const MAX_VISITS = 'maxVisits';
|
||||
public const FIND_IF_EXISTS = 'findIfExists';
|
||||
public const DOMAIN = 'domain';
|
||||
public const SHORT_CODE_LENGTH = 'shortCodeLength';
|
||||
public const LONG_URL = 'longUrl';
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
@@ -28,6 +33,8 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
$this->add($this->createInput(self::LONG_URL, false));
|
||||
|
||||
$validSince = $this->createInput(self::VALID_SINCE, false);
|
||||
$validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
|
||||
$this->add($validSince);
|
||||
@@ -36,14 +43,18 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
$validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTime::ATOM]));
|
||||
$this->add($validUntil);
|
||||
|
||||
$customSlug = $this->createInput(self::CUSTOM_SLUG, false);
|
||||
// FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's
|
||||
// empty, is by using the deprecated setContinueIfEmpty
|
||||
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
|
||||
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter());
|
||||
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
|
||||
Validator\NotEmpty::STRING,
|
||||
Validator\NotEmpty::SPACE,
|
||||
]));
|
||||
$this->add($customSlug);
|
||||
|
||||
$maxVisits = $this->createInput(self::MAX_VISITS, false);
|
||||
$maxVisits->getValidatorChain()->attach(new Validator\Digits())
|
||||
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
|
||||
$this->add($maxVisits);
|
||||
$this->add($this->createPositiveNumberInput(self::MAX_VISITS));
|
||||
$this->add($this->createPositiveNumberInput(self::SHORT_CODE_LENGTH, MIN_SHORT_CODES_LENGTH));
|
||||
|
||||
$this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false));
|
||||
|
||||
@@ -51,4 +62,13 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
$domain->getValidatorChain()->attach(new Validation\HostAndPortValidator());
|
||||
$this->add($domain);
|
||||
}
|
||||
|
||||
private function createPositiveNumberInput(string $name, int $min = 1): Input
|
||||
{
|
||||
$input = $this->createInput($name, false);
|
||||
$input->getValidatorChain()->attach(new Validator\Digits())
|
||||
->attach(new Validator\GreaterThan(['min' => $min, 'inclusive' => true]));
|
||||
|
||||
return $input;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class ShortUrlsParamsInputFilter extends InputFilter
|
||||
|
||||
$tags = $this->createArrayInput(self::TAGS, false);
|
||||
$tags->getFilterChain()->attach(new Filter\StringToLower())
|
||||
->attach(new Validation\SluggerFilter());
|
||||
->attach(new Filter\PregReplace(['pattern' => '/ /', 'replacement' => '-']));
|
||||
$this->add($tags);
|
||||
}
|
||||
}
|
||||
|
||||
20
module/Core/src/Visit/VisitGeolocationHelperInterface.php
Normal file
20
module/Core/src/Visit/VisitGeolocationHelperInterface.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
interface VisitGeolocationHelperInterface
|
||||
{
|
||||
/**
|
||||
* @throws IpCannotBeLocatedException
|
||||
*/
|
||||
public function geolocateVisit(Visit $visit): Location;
|
||||
|
||||
public function onVisitLocated(VisitLocation $visitLocation, Visit $visit): void;
|
||||
}
|
||||
94
module/Core/src/Visit/VisitLocator.php
Normal file
94
module/Core/src/Visit/VisitLocator.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Exception\IpCannotBeLocatedException;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
|
||||
class VisitLocator implements VisitLocatorInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private VisitRepositoryInterface $repo;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $em->getRepository(Visit::class);
|
||||
$this->repo = $repo;
|
||||
}
|
||||
|
||||
public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$this->locateVisits($this->repo->findUnlocatedVisits(), $helper);
|
||||
}
|
||||
|
||||
public function locateVisitsWithEmptyLocation(VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$this->locateVisits($this->repo->findVisitsWithEmptyLocation(), $helper);
|
||||
}
|
||||
|
||||
public function locateAllVisits(VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$this->locateVisits($this->repo->findAllVisits(), $helper);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable|Visit[] $results
|
||||
*/
|
||||
private function locateVisits(iterable $results, VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$count = 0;
|
||||
$persistBlock = 200;
|
||||
|
||||
foreach ($results as $visit) {
|
||||
$count++;
|
||||
|
||||
try {
|
||||
$location = $helper->geolocateVisit($visit);
|
||||
} catch (IpCannotBeLocatedException $e) {
|
||||
if (! $e->isNonLocatableAddress()) {
|
||||
// Skip if the visit's IP could not be located because of an error
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the IP address is non-locatable, locate it as empty to prevent next processes to pick it again
|
||||
$location = Location::emptyInstance();
|
||||
}
|
||||
|
||||
$location = new VisitLocation($location);
|
||||
$this->locateVisit($visit, $location, $helper);
|
||||
|
||||
// Flush and clear after X iterations
|
||||
if ($count % $persistBlock === 0) {
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
}
|
||||
|
||||
private function locateVisit(Visit $visit, VisitLocation $location, VisitGeolocationHelperInterface $helper): void
|
||||
{
|
||||
$prevLocation = $visit->getVisitLocation();
|
||||
|
||||
$visit->locate($location);
|
||||
$this->em->persist($visit);
|
||||
|
||||
// In order to avoid leaving orphan locations, remove the previous one
|
||||
if ($prevLocation !== null) {
|
||||
$this->em->remove($prevLocation);
|
||||
}
|
||||
|
||||
$helper->onVisitLocated($location, $visit);
|
||||
}
|
||||
}
|
||||
14
module/Core/src/Visit/VisitLocatorInterface.php
Normal file
14
module/Core/src/Visit/VisitLocatorInterface.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
interface VisitLocatorInterface
|
||||
{
|
||||
public function locateUnlocatedVisits(VisitGeolocationHelperInterface $helper): void;
|
||||
|
||||
public function locateVisitsWithEmptyLocation(VisitGeolocationHelperInterface $helper): void;
|
||||
|
||||
public function locateAllVisits(VisitGeolocationHelperInterface $helper): void;
|
||||
}
|
||||
@@ -40,15 +40,23 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
* @test
|
||||
* @dataProvider provideBlockSize
|
||||
*/
|
||||
public function findUnlocatedVisitsReturnsProperVisits(int $blockSize): void
|
||||
public function findVisitsReturnsProperVisits(int $blockSize): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$countIterable = function (iterable $results): int {
|
||||
$resultsCount = 0;
|
||||
foreach ($results as $value) {
|
||||
$resultsCount++;
|
||||
}
|
||||
|
||||
return $resultsCount;
|
||||
};
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
if ($i % 2 === 0) {
|
||||
if ($i >= 2) {
|
||||
$location = new VisitLocation(Location::emptyInstance());
|
||||
$this->getEntityManager()->persist($location);
|
||||
$visit->locate($location);
|
||||
@@ -58,18 +66,20 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$resultsCount = 0;
|
||||
$results = $this->repo->findUnlocatedVisits(true, $blockSize);
|
||||
foreach ($results as $value) {
|
||||
$resultsCount++;
|
||||
}
|
||||
$withEmptyLocation = $this->repo->findVisitsWithEmptyLocation($blockSize);
|
||||
$unlocated = $this->repo->findUnlocatedVisits($blockSize);
|
||||
$all = $this->repo->findAllVisits($blockSize);
|
||||
|
||||
$this->assertEquals(3, $resultsCount);
|
||||
// Important! assertCount will not work here, as this iterable object loads data dynamically and the count
|
||||
// is 0 if not iterated
|
||||
$this->assertEquals(2, $countIterable($unlocated));
|
||||
$this->assertEquals(4, $countIterable($withEmptyLocation));
|
||||
$this->assertEquals(6, $countIterable($all));
|
||||
}
|
||||
|
||||
public function provideBlockSize(): iterable
|
||||
{
|
||||
return map(range(1, 5), fn (int $value) => [$value]);
|
||||
return map(range(1, 10), fn (int $value) => [$value]);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -30,7 +30,7 @@ class QrCodeActionTest extends TestCase
|
||||
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
|
||||
$this->action = new QrCodeAction($router->reveal(), $this->urlResolver->reveal());
|
||||
$this->action = new QrCodeAction($this->urlResolver->reveal(), ['domain' => 'doma.in']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Action;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
@@ -89,4 +91,23 @@ class RedirectActionTest extends TestCase
|
||||
|
||||
$handle->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function trackingIsDisabledWhenRequestIsForwardedFromHead(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn($shortUrl);
|
||||
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
|
||||
});
|
||||
|
||||
$request = (new ServerRequest())->withAttribute('shortCode', $shortCode)
|
||||
->withAttribute(
|
||||
ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE,
|
||||
RequestMethodInterface::METHOD_HEAD,
|
||||
);
|
||||
$this->action->process($request, $this->prophesize(RequestHandlerInterface::class)->reveal());
|
||||
|
||||
$track->shouldNotHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'validate_url' => true,
|
||||
'delete_short_url_threshold' => 50,
|
||||
'invalid_short_url_redirect_to' => 'foobar.com',
|
||||
'regular_404_redirect_to' => 'bar.com',
|
||||
'base_url_redirect_to' => 'foo.com',
|
||||
'redis_servers' => [
|
||||
'tcp://1.1.1.1:1111',
|
||||
'tcp://1.2.2.2:2222',
|
||||
@@ -57,6 +59,10 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'http://my-api.com/api/v2.3/notify',
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
'mercure_public_hub_url' => 'public_url',
|
||||
'mercure_internal_hub_url' => 'internal_url',
|
||||
'mercure_jwt_secret' => 'super_secret_value',
|
||||
];
|
||||
$expected = [
|
||||
'app_options' => [
|
||||
@@ -84,6 +90,7 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
'http://my-api.com/api/v2.3/notify',
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
@@ -112,6 +119,8 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
|
||||
'not_found_redirects' => [
|
||||
'invalid_short_url' => 'foobar.com',
|
||||
'regular_404' => 'bar.com',
|
||||
'base_url' => 'foo.com',
|
||||
],
|
||||
|
||||
'mezzio-swoole' => [
|
||||
@@ -121,6 +130,12 @@ class SimplifiedConfigParserTest extends TestCase
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => 'public_url',
|
||||
'internal_hub_url' => 'internal_url',
|
||||
'jwt_secret' => 'super_secret_value',
|
||||
],
|
||||
];
|
||||
|
||||
$result = ($this->postProcessor)(array_merge($config, $simplified));
|
||||
|
||||
@@ -8,6 +8,13 @@ use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
use function strlen;
|
||||
|
||||
use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
|
||||
|
||||
class ShortUrlTest extends TestCase
|
||||
{
|
||||
@@ -48,4 +55,23 @@ class ShortUrlTest extends TestCase
|
||||
|
||||
$this->assertNotEquals($firstShortCode, $secondShortCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideLengths
|
||||
*/
|
||||
public function shortCodesHaveExpectedLength(?int $length, int $expectedLength): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(
|
||||
[ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $length],
|
||||
));
|
||||
|
||||
$this->assertEquals($expectedLength, strlen($shortUrl->getShortCode()));
|
||||
}
|
||||
|
||||
public function provideLengths(): iterable
|
||||
{
|
||||
yield [null, DEFAULT_SHORT_CODES_LENGTH];
|
||||
yield from map(range(4, 10), fn (int $value) => [$value, $value]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListenerDelegator;
|
||||
|
||||
class CloseDbConnectionEventListenerDelegatorTest extends TestCase
|
||||
{
|
||||
private CloseDbConnectionEventListenerDelegator $delegator;
|
||||
private ObjectProphecy $container;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->container = $this->prophesize(ContainerInterface::class);
|
||||
$this->delegator = new CloseDbConnectionEventListenerDelegator();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properDependenciesArePassed(): void
|
||||
{
|
||||
$callbackInvoked = false;
|
||||
$callback = function () use (&$callbackInvoked): callable {
|
||||
$callbackInvoked = true;
|
||||
|
||||
return function (): void {
|
||||
};
|
||||
};
|
||||
|
||||
$em = $this->prophesize(ReopeningEntityManagerInterface::class);
|
||||
$getEm = $this->container->get('em')->willReturn($em->reveal());
|
||||
|
||||
($this->delegator)($this->container->reveal(), '', $callback);
|
||||
|
||||
$this->assertTrue($callbackInvoked);
|
||||
$getEm->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListener;
|
||||
use stdClass;
|
||||
use Throwable;
|
||||
|
||||
class CloseDbConnectionEventListenerTest extends TestCase
|
||||
{
|
||||
private ObjectProphecy $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(ReopeningEntityManagerInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideWrapped
|
||||
*/
|
||||
public function connectionIsOpenedBeforeAndClosedAfter(callable $wrapped, bool &$wrappedWasCalled): void
|
||||
{
|
||||
$conn = $this->prophesize(Connection::class);
|
||||
$close = $conn->close()->will(function (): void {
|
||||
});
|
||||
$getConn = $this->em->getConnection()->willReturn($conn->reveal());
|
||||
$clear = $this->em->clear()->will(function (): void {
|
||||
});
|
||||
$open = $this->em->open()->will(function (): void {
|
||||
});
|
||||
|
||||
$eventListener = new CloseDbConnectionEventListener($this->em->reveal(), $wrapped);
|
||||
|
||||
try {
|
||||
($eventListener)(new stdClass());
|
||||
} catch (Throwable $e) {
|
||||
// Ignore exceptions
|
||||
}
|
||||
|
||||
$this->assertTrue($wrappedWasCalled);
|
||||
$close->shouldHaveBeenCalledOnce();
|
||||
$getConn->shouldHaveBeenCalledOnce();
|
||||
$clear->shouldHaveBeenCalledOnce();
|
||||
$open->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideWrapped(): iterable
|
||||
{
|
||||
yield 'does not throw exception' => (function (): array {
|
||||
$wrappedWasCalled = false;
|
||||
$wrapped = function () use (&$wrappedWasCalled): void {
|
||||
$wrappedWasCalled = true;
|
||||
};
|
||||
|
||||
return [$wrapped, &$wrappedWasCalled];
|
||||
})();
|
||||
yield 'throws exception' => (function (): array {
|
||||
$wrappedWasCalled = false;
|
||||
$wrapped = function () use (&$wrappedWasCalled): void {
|
||||
$wrappedWasCalled = true;
|
||||
throw new RuntimeException('Some error');
|
||||
};
|
||||
|
||||
return [$wrapped, &$wrappedWasCalled];
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
@@ -130,13 +129,16 @@ class LocateShortUrlVisitTest extends TestCase
|
||||
yield 'localhost' => [new Visit($shortUrl, new Visitor('', '', IpAddress::LOCALHOST))];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function locatableVisitsResolveToLocation(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideIpAddresses
|
||||
*/
|
||||
public function locatableVisitsResolveToLocation(string $anonymizedIpAddress, ?string $originalIpAddress): void
|
||||
{
|
||||
$ipAddr = '1.2.3.0';
|
||||
$ipAddr = $originalIpAddress ?? $anonymizedIpAddress;
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('', '', $ipAddr));
|
||||
$location = new Location('', '', '', '', 0.0, 0.0, '');
|
||||
$event = new ShortUrlVisited('123');
|
||||
$event = new ShortUrlVisited('123', $originalIpAddress);
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn($visit);
|
||||
$flush = $this->em->flush()->will(function (): void {
|
||||
@@ -155,6 +157,12 @@ class LocateShortUrlVisitTest extends TestCase
|
||||
$dispatch->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideIpAddresses(): iterable
|
||||
{
|
||||
yield 'no original IP address' => ['1.2.3.0', null];
|
||||
yield 'original IP address' => ['1.2.3.0', '1.2.3.4'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void
|
||||
{
|
||||
@@ -209,7 +217,7 @@ class LocateShortUrlVisitTest extends TestCase
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
$this->assertEquals($visit->getVisitLocation(), new UnknownVisitLocation());
|
||||
$this->assertNull($visit->getVisitLocation());
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldNotHaveBeenCalled();
|
||||
$resolveIp->shouldNotHaveBeenCalled();
|
||||
|
||||
123
module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php
Normal file
123
module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Symfony\Component\Mercure\PublisherInterface;
|
||||
use Symfony\Component\Mercure\Update;
|
||||
|
||||
class NotifyVisitToMercureTest extends TestCase
|
||||
{
|
||||
private NotifyVisitToMercure $listener;
|
||||
private ObjectProphecy $publisher;
|
||||
private ObjectProphecy $updatesGenerator;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $logger;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->publisher = $this->prophesize(PublisherInterface::class);
|
||||
$this->updatesGenerator = $this->prophesize(MercureUpdatesGeneratorInterface::class);
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->logger = $this->prophesize(LoggerInterface::class);
|
||||
|
||||
$this->listener = new NotifyVisitToMercure(
|
||||
$this->publisher->reveal(),
|
||||
$this->updatesGenerator->reveal(),
|
||||
$this->em->reveal(),
|
||||
$this->logger->reveal(),
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function notificationsAreNotSentWhenVisitCannotBeFound(): void
|
||||
{
|
||||
$visitId = '123';
|
||||
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null);
|
||||
$logWarning = $this->logger->warning(
|
||||
'Tried to notify mercure for visit with id "{visitId}", but it does not exist.',
|
||||
['visitId' => $visitId],
|
||||
);
|
||||
$logDebug = $this->logger->debug(Argument::cetera());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate(
|
||||
Argument::type(Visit::class),
|
||||
)->willReturn(new Update('', ''));
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class))->willReturn(
|
||||
new Update('', ''),
|
||||
);
|
||||
$publish = $this->publisher->__invoke(Argument::type(Update::class));
|
||||
|
||||
($this->listener)(new VisitLocated($visitId));
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldHaveBeenCalledOnce();
|
||||
$logDebug->shouldNotHaveBeenCalled();
|
||||
$buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function notificationsAreSentWhenVisitIsFound(): void
|
||||
{
|
||||
$visitId = '123';
|
||||
$visit = new Visit(new ShortUrl(''), Visitor::emptyInstance());
|
||||
$update = new Update('', '');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
|
||||
$logWarning = $this->logger->warning(Argument::cetera());
|
||||
$logDebug = $this->logger->debug(Argument::cetera());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
|
||||
$publish = $this->publisher->__invoke($update);
|
||||
|
||||
($this->listener)(new VisitLocated($visitId));
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logDebug->shouldNotHaveBeenCalled();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$publish->shouldHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function debugIsLoggedWhenExceptionIsThrown(): void
|
||||
{
|
||||
$visitId = '123';
|
||||
$visit = new Visit(new ShortUrl(''), Visitor::emptyInstance());
|
||||
$update = new Update('', '');
|
||||
$e = new RuntimeException('Error');
|
||||
|
||||
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
|
||||
$logWarning = $this->logger->warning(Argument::cetera());
|
||||
$logDebug = $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [
|
||||
'e' => $e,
|
||||
]);
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
|
||||
$publish = $this->publisher->__invoke($update)->willThrow($e);
|
||||
|
||||
($this->listener)(new VisitLocated($visitId));
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logDebug->shouldHaveBeenCalledOnce();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
66
module/Core/test/Mercure/MercureUpdatesGeneratorTest.php
Normal file
66
module/Core/test/Mercure/MercureUpdatesGeneratorTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Mercure;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGenerator;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
||||
use function Shlinkio\Shlink\Common\json_decode;
|
||||
|
||||
class MercureUpdatesGeneratorTest extends TestCase
|
||||
{
|
||||
private MercureUpdatesGenerator $generator;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->generator = new MercureUpdatesGenerator([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideMethod
|
||||
*/
|
||||
public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['customSlug' => 'foo']));
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
$update = $this->generator->{$method}($visit);
|
||||
|
||||
$this->assertEquals([$expectedTopic], $update->getTopics());
|
||||
$this->assertEquals([
|
||||
'shortUrl' => [
|
||||
'shortCode' => $shortUrl->getShortCode(),
|
||||
'shortUrl' => 'http:/' . $shortUrl->getShortCode(),
|
||||
'longUrl' => '',
|
||||
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
|
||||
'visitsCount' => 0,
|
||||
'tags' => [],
|
||||
'meta' => [
|
||||
'validSince' => null,
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'domain' => null,
|
||||
],
|
||||
'visit' => [
|
||||
'referer' => '',
|
||||
'userAgent' => '',
|
||||
'visitLocation' => null,
|
||||
'date' => $visit->getDate()->toAtomString(),
|
||||
],
|
||||
], json_decode($update->getData()));
|
||||
}
|
||||
|
||||
public function provideMethod(): iterable
|
||||
{
|
||||
yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit'];
|
||||
yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo'];
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Middleware;
|
||||
|
||||
use Doctrine\Common\Cache\ArrayCache;
|
||||
use Doctrine\Common\Cache\Cache;
|
||||
use Laminas\Diactoros\Response;
|
||||
use Laminas\Diactoros\ServerRequest;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Middleware\QrCodeCacheMiddleware;
|
||||
|
||||
class QrCodeCacheMiddlewareTest extends TestCase
|
||||
{
|
||||
private QrCodeCacheMiddleware $middleware;
|
||||
private Cache $cache;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->cache = new ArrayCache();
|
||||
$this->middleware = new QrCodeCacheMiddleware($this->cache);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function noCachedPathFallsBackToNextMiddleware(): void
|
||||
{
|
||||
$delegate = $this->prophesize(RequestHandlerInterface::class);
|
||||
$delegate->handle(Argument::any())->willReturn(new Response())->shouldBeCalledOnce();
|
||||
|
||||
$this->middleware->process((new ServerRequest())->withUri(new Uri('/foo/bar')), $delegate->reveal());
|
||||
|
||||
$this->assertTrue($this->cache->contains('/foo/bar'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cachedPathReturnsCacheContent(): void
|
||||
{
|
||||
$isCalled = false;
|
||||
$uri = (new Uri())->withPath('/foo');
|
||||
$this->cache->save('/foo', ['body' => 'the body', 'content-type' => 'image/png']);
|
||||
$delegate = $this->prophesize(RequestHandlerInterface::class);
|
||||
|
||||
$resp = $this->middleware->process((new ServerRequest())->withUri($uri), $delegate->reveal());
|
||||
|
||||
$this->assertFalse($isCalled);
|
||||
$resp->getBody()->rewind();
|
||||
$this->assertEquals('the body', $resp->getBody()->getContents());
|
||||
$this->assertEquals('image/png', $resp->getHeaderLine('Content-Type'));
|
||||
$delegate->handle(Argument::any())->shouldHaveBeenCalledTimes(0);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user