Compare commits

..

107 Commits

Author SHA1 Message Date
Alejandro Celaya
cb6756d801 Merge pull request #763 from shlinkio/develop
Release 2.2.0
2020-05-09 11:10:31 +02:00
Alejandro Celaya
cf605407ad Used definitive dependency versions for shlink-common and shlñink-installer 2020-05-09 10:56:07 +02:00
Alejandro Celaya
1a4eee1c81 Merge pull request #762 from acelaya-forks/feature/visits-by-tag
Feature/visits by tag
2020-05-09 10:52:33 +02:00
Alejandro Celaya
4c5cd88041 Updated changelog 2020-05-09 10:38:18 +02:00
Alejandro Celaya
4d346d1fea Created API test for tags visits endpoint 2020-05-09 10:31:39 +02:00
Alejandro Celaya
7f39e6d768 Created TagVisitsActionTest 2020-05-09 10:22:07 +02:00
Alejandro Celaya
9b9de8e290 Updated VisitsTrackerTest 2020-05-09 10:14:26 +02:00
Alejandro Celaya
e1e3c7f061 Created paginator adapter tests 2020-05-09 10:10:48 +02:00
Alejandro Celaya
3218f8c283 Added Created endpoint to serve visits by tag 2020-05-09 09:53:45 +02:00
Alejandro Celaya
f0acce1be0 Updated to latest common 2020-05-09 09:34:59 +02:00
Alejandro Celaya
dd4b4277c9 Added test for VisitRepository tag methods 2020-05-08 20:11:37 +02:00
Alejandro Celaya
baf77b6ffb Implemented methods to get paginated list of visits by tag, reusing methods used for short code filtering 2020-05-08 19:55:05 +02:00
Alejandro Celaya
5be882a31b Improved parameter definition in some private queries in VisitRepository 2020-05-08 19:41:21 +02:00
Alejandro Celaya
ae060f3b13 Merge pull request #761 from acelaya-forks/feature/optional-obfuscation
Feature/optional obfuscation
2020-05-08 16:03:11 +02:00
Alejandro Celaya
e8ab664561 Updated changelog 2020-05-08 15:54:50 +02:00
Alejandro Celaya
f4bf3551f6 Updated shlink-installer to a version supporting IP anonymization param 2020-05-08 15:50:16 +02:00
Alejandro Celaya
8f06e4b20f Replaced references to obfuscate by anonymize 2020-05-08 15:43:09 +02:00
Alejandro Celaya
bfdd6e0c50 Ensured SimplifiedConfigParser properly handles obfuscate_remote_addr option 2020-05-08 13:21:49 +02:00
Alejandro Celaya
ba13d99a71 Allowed remote addr obfuscation to be configured on docker image by using the OBFUSCATE_REMOTE_ADDR env var 2020-05-08 13:19:40 +02:00
Alejandro Celaya
eac468514b Allow to determine if remote addresses should be obfuscated at configuration level 2020-05-08 13:10:58 +02:00
Alejandro Celaya
7da00fbc8c Updated Visit entity so that the address can be optionally obfuscated 2020-05-08 12:58:49 +02:00
Alejandro Celaya
4b7c54d7a9 Merge pull request #760 from acelaya-forks/feature/list-tags-command
Updated ListTagsCommand so that it displays extended information
2020-05-08 12:57:35 +02:00
Alejandro Celaya
c336bb1901 Updated ListTagsCommand so that it displays extended information 2020-05-08 12:39:02 +02:00
Alejandro Celaya
fbb1c449da Merge pull request #759 from acelaya-forks/feature/improved-tags-endpoint
Feature/improved tags endpoint
2020-05-08 12:17:32 +02:00
Alejandro Celaya
252cc7f49d Updated changelog 2020-05-08 11:53:26 +02:00
Alejandro Celaya
00cac4ba72 Created rest test for list tags action 2020-05-08 11:51:28 +02:00
Alejandro Celaya
91aaffc6db Updated ListTagsActionTest 2020-05-08 11:32:06 +02:00
Alejandro Celaya
2e269bcacd Updated TagServiceTest 2020-05-08 11:14:39 +02:00
Alejandro Celaya
bdd14427d9 Added tests for TagRepository::findTagsWithInfo 2020-05-08 11:09:28 +02:00
Alejandro Celaya
06c59fe2dd Fixed invalid imports after class refactoring 2020-05-08 10:29:24 +02:00
Alejandro Celaya
9a78fd1a26 Fixed definition of inversed many to many entity relationship 2020-05-08 10:25:33 +02:00
Alejandro Celaya
626c92460b Enhanced list tags endpoint so that it can also return stats foir every tag 2020-05-08 10:15:33 +02:00
Alejandro Celaya
7e0a14493e Documented updates on the tags endpoint to return more detailed information 2020-05-08 10:14:38 +02:00
Alejandro Celaya
8d23e60d3a Merge pull request #758 from acelaya-forks/feature/non-stable-alpha
Ensured stable tag is not pushed when building docker image for alpha or beta versions
2020-05-07 10:57:52 +02:00
Alejandro Celaya
5f0293bc21 Ensured stable tag is not pushed when building docker image for alpha or beta versions 2020-05-07 10:45:53 +02:00
Alejandro Celaya
afe7381263 Merge pull request #757 from acelaya-forks/feature/docker-img-impr
Feature/docker img impr
2020-05-07 10:31:32 +02:00
Alejandro Celaya
b75922f1d3 Updated changelog 2020-05-07 10:17:34 +02:00
Alejandro Celaya
d9ae83a92b Updated everything related with dependencies in docker images 2020-05-07 10:16:20 +02:00
Alejandro Celaya
22cc9ace4d Merge pull request #755 from acelaya-forks/feature/fix-logged-remote-ip
Feature/fix logged remote ip
2020-05-05 13:04:02 +02:00
Alejandro Celaya
53a37feafe Updated changelogs 2020-05-05 12:54:08 +02:00
Alejandro Celaya
0cab51b01b Enforced mezzio-swoole 2.6.4 or greater 2020-05-05 12:51:47 +02:00
Alejandro Celaya
5f258b6a28 Merge pull request #752 from acelaya-forks/feature/travis-db-tests
Feature/travis db tests
2020-05-04 22:06:04 +02:00
Alejandro Celaya
cc41c51f77 Removed duplicated pdo_sqlsrv enabling on travis config 2020-05-04 21:55:18 +02:00
Alejandro Celaya
5f42266cf2 Moved ms odbc commands to a script 2020-05-04 21:48:54 +02:00
Alejandro Celaya
522d8ed236 Ensured some commands are run as sudo during travis CI 2020-05-04 21:33:19 +02:00
Alejandro Celaya
78359c28c7 Added MS ODBC package installation to travis 2020-05-04 21:22:41 +02:00
Alejandro Celaya
13bb48d068 Installed pdo_sqlsrv extension in travis 2020-05-04 21:12:49 +02:00
Alejandro Celaya
f6d9a83202 Moved initial ci databases to specific docker-compose file 2020-05-04 21:00:09 +02:00
Alejandro Celaya
dfdae96da5 Added commands to initially create all testing database for all database engines in travis 2020-05-04 20:34:28 +02:00
Alejandro Celaya
9f13063b1f Fixed docker-compose command run in travis 2020-05-04 20:02:48 +02:00
Alejandro Celaya
1e8c36b5f1 Updated changelog 2020-05-04 19:55:52 +02:00
Alejandro Celaya
e747a0b250 Updated how database tests are run in travis, so that all DB engines are covered 2020-05-04 19:55:03 +02:00
Alejandro Celaya
79b8834c61 Merge pull request #748 from acelaya-forks/feature/visits-perf-improvements
Feature/visits perf improvements
2020-05-03 20:11:40 +02:00
Alejandro Celaya
313b6a59b9 Updated changelog 2020-05-03 20:02:50 +02:00
Alejandro Celaya
d5288f756e Fixed entity mapping for visits without a visit location 2020-05-03 19:52:40 +02:00
Alejandro Celaya
867659ea25 Created index on visits.date column 2020-05-03 19:15:26 +02:00
Alejandro Celaya
74ad3553cb Hardcoded types on date fields when filtering visits lists 2020-05-03 19:02:13 +02:00
Alejandro Celaya
8b0ce8e6f3 Improved performance when loading visits chuncks at high offsets 2020-05-03 18:20:01 +02:00
Alejandro Celaya
0e4bccc4bb Cached result of the count query on VisitsPaginatorAdapter 2020-05-03 10:44:01 +02:00
Alejandro Celaya
c4ae89a279 Removed DISTINCT when counting visits for a short URL 2020-05-03 10:22:00 +02:00
Alejandro Celaya
80d41db901 Improved performance on query that returns the list of visits for a short URL 2020-05-02 22:47:59 +02:00
Alejandro Celaya
6c30fc73ee Added swoole reverse proxy container 2020-05-02 12:04:42 +02:00
Alejandro Celaya
56932e4ea6 Disabled swoole coroutines 2020-05-01 18:24:48 +02:00
Alejandro Celaya
84b38c4940 Merge pull request #745 from acelaya-forks/feature/general-visits
Feature/general visits
2020-05-01 12:16:22 +02:00
Alejandro Celaya
aece9e68ba Removed logger dependency from rest actions 2020-05-01 12:08:44 +02:00
Alejandro Celaya
d067f52ac2 Updated changelog 2020-05-01 11:58:59 +02:00
Alejandro Celaya
b5947d1642 Created more unit tests 2020-05-01 11:57:46 +02:00
Alejandro Celaya
3232ab401f Documented new visits endpoint 2020-05-01 11:44:55 +02:00
Alejandro Celaya
1ef10f11cb Created new action to get default visit stats 2020-05-01 11:40:02 +02:00
Alejandro Celaya
5beaab85ac Renamed GetVisitsAction to ShortUrlVisitsAction 2020-05-01 11:17:07 +02:00
Alejandro Celaya
4498386f56 Fixed merge conflicts 2020-04-30 20:26:00 +02:00
Alejandro Celaya
704958994d Merge pull request #738 from acelaya-forks/feature/health-fix
Feature/health fix
2020-04-25 20:07:09 +02:00
Alejandro Celaya
a6864bca7c Updated changelog 2020-04-25 20:00:01 +02:00
Alejandro Celaya
15a8305209 Fixed random 503 responses from the HealthAction when the database connection injected on it has expired 2020-04-25 19:58:49 +02:00
Alejandro Celaya
469b70d708 Merge pull request #737 from acelaya-forks/feature/installation-error
Fixed error when cleaning metadata cache during installation with APC…
2020-04-25 19:30:06 +02:00
Alejandro Celaya
4f988d223b Fixed error when cleaning metadata cache during installation with APCu enabled 2020-04-25 19:13:47 +02:00
Alejandro Celaya
e95abc4efb Merge pull request #736 from acelaya-forks/feature/mercure-proxy
Configured an nginx container acting as a reverse proxy for the mercu…
2020-04-25 13:56:07 +02:00
Alejandro Celaya
4917e53acd Configured an nginx container acting as a reverse proxy for the mercure container 2020-04-25 13:44:09 +02:00
Alejandro Celaya
45db4c321a Merge pull request #731 from acelaya-forks/feature/fix-local-sqlite-tests
Ensured mysql config is not loaded for sqlite test envs
2020-04-18 14:06:44 +02:00
Alejandro Celaya
e6d914cfe1 Ensured mysql config is not loaded for sqlite test envs 2020-04-18 14:01:24 +02:00
Alejandro Celaya
85714c931d Merge pull request #730 from acelaya-forks/feature/fix-mysql-buffered-error
Feature/fix mysql buffered error
2020-04-18 13:29:24 +02:00
Alejandro Celaya
66a7f279c2 Updated changelog 2020-04-18 13:22:51 +02:00
Alejandro Celaya
7c6827ea9f Added MYSQL_ATTR_USE_BUFFERED_QUERY driver option with value true for mysql/maria connections 2020-04-18 13:21:46 +02:00
Alejandro Celaya
078c8ea011 Changed default mercure token duration to 1 day 2020-04-18 11:29:49 +02:00
Alejandro Celaya
655fd58a9d Added async API spec file 2020-04-16 22:44:08 +02:00
Alejandro Celaya
6ba6b951bf Changed mercure topics to be dash-cased 2020-04-16 22:25:12 +02:00
Alejandro Celaya
8e0e11f3b3 Merge pull request #727 from acelaya-forks/feature/mercure-improvement
Feature/mercure improvement
2020-04-14 21:16:16 +02:00
Alejandro Celaya
18b12ab1e6 Updated NotifyVisitToMercure to send both an update for all short URLs and one specific short URL 2020-04-14 20:57:25 +02:00
Alejandro Celaya
3908f63b0d Updated to latest installer version 2020-04-14 20:30:05 +02:00
Alejandro Celaya
ca2c32fa8c Removed no-longer used dependencies 2020-04-14 20:24:36 +02:00
Alejandro Celaya
a3a3ac1859 Added missing escaped characters 2020-04-13 13:23:26 +02:00
Alejandro Celaya
f5e0d0c2b1 Merge pull request #726 from acelaya-forks/feature/mercure-integration
Feature/mercure integration
2020-04-13 10:03:12 +02:00
Alejandro Celaya
ba0678946f Updated installer to use a version supporting mercure options 2020-04-13 09:38:18 +02:00
Alejandro Celaya
934fa937b5 Updated config parsers for docker image to accept new mercure env vars and configs 2020-04-12 20:41:23 +02:00
Alejandro Celaya
8d888cb43d Documented how to use a mercure hub when using the docker image 2020-04-12 18:39:28 +02:00
Alejandro Celaya
7f888c49b4 Created MercureUpdatesGeneratorTest 2020-04-12 18:01:13 +02:00
Alejandro Celaya
e97dfbfdda Created NotifyVisitToMercureTest 2020-04-12 17:50:40 +02:00
Alejandro Celaya
b858d79b9e Fixed mercure hub URL returned by MercureInfoAction 2020-04-12 17:50:09 +02:00
Alejandro Celaya
72d8edf4ff Created event listener that notifies mercure hub for new visits 2020-04-12 17:05:59 +02:00
Alejandro Celaya
31db97228d Created MercureInfoActionTest 2020-04-12 14:22:23 +02:00
Alejandro Celaya
2ffbf03cf8 Created action to get mercure integration info 2020-04-12 13:59:10 +02:00
Alejandro Celaya
85440c1c5f Improved mercure-related configs 2020-04-12 12:21:05 +02:00
Alejandro Celaya
69962f1fe8 Added package to handle JWTs 2020-04-11 18:10:56 +02:00
Alejandro Celaya
10cad33248 Added configuration for mercure integration 2020-04-11 18:10:56 +02:00
Alejandro Celaya
0c9deca3f8 Added symfony/mercure package and a container for development 2020-04-11 18:10:56 +02:00
Alejandro Celaya
e1cd4a6ee3 Merge pull request #724 from acelaya-forks/feature/clean-tasks
Created decorator for database connection closing and reopening for s…
2020-04-11 18:09:26 +02:00
Alejandro Celaya
f915b97606 Created decorator for database connection closing and reopening for swoole tasks 2020-04-11 18:00:40 +02:00
122 changed files with 2911 additions and 402 deletions

View File

@@ -8,8 +8,6 @@ php:
- '7.4'
services:
- mysql
- postgresql
- docker
cache:
@@ -17,8 +15,10 @@ cache:
- $HOME/.composer/cache/files
before_install:
- sudo ./data/infra/ci/install-ms-odbc.sh
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
- yes | pecl install pdo_sqlsrv swoole-4.4.18
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
- yes | pecl install swoole-4.4.15
- phpenv config-rm xdebug.ini || return 0
install:
@@ -26,8 +26,7 @@ install:
- composer install --no-interaction --prefer-dist
before_script:
- mysql -e 'CREATE DATABASE shlink_test;'
- psql -c 'create database shlink_test;' -U postgres
- docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
- mkdir build
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile)

View File

@@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## 2.2.0 - 2020-05-09
#### 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 subscribe to updates.
* [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats.
* [#674](https://github.com/shlinkio/shlink/issues/674) Added new `[GET /tags/{tag}/visits]` rest endpoint which returns visits by tag.
It works in the same way as the `[GET /short-urls/{shortCode}/visits]` one, returning the same response payload, and supporting the same query params, but the response is the list of visits in all short URLs which have provided tag.
* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag.
Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag.
Also, the `tag:list` CLI command has been changed and it always behaves like this.
* [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation.
#### Changed
* [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets.
* [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered.
* [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed.
#### 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.
* [#732](https://github.com/shlinkio/shlink/issues/732) Fixed wrong client IP in access logs when serving app with swoole behind load balancer.
## 2.1.4 - 2020-04-30
#### Added

View File

@@ -1,8 +1,8 @@
FROM php:7.4.2-alpine3.11 as base
FROM php:7.4.5-alpine3.11 as base
ARG SHLINK_VERSION=2.0.5
ARG SHLINK_VERSION=2.1.4
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.4.15
ENV SWOOLE_VERSION 4.4.18
ENV LC_ALL "C"
WORKDIR /etc/shlink
@@ -25,15 +25,12 @@ RUN \
# 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
rm msodbcsql17_17.5.1.1-1_amd64.apk
# Install shlink

View File

@@ -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,13 +32,14 @@
"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.6",
"mezzio/mezzio-swoole": "^2.6.4",
"monolog/monolog": "^2.0",
"nikolaposa/monolog-factory": "^3.0",
"ocramius/proxy-manager": "^2.7.0",
@@ -49,14 +48,15 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "^3.0",
"shlinkio/shlink-common": "^3.1.0",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-installer": "^4.4.0",
"shlinkio/shlink-installer": "^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": {
@@ -109,7 +109,7 @@
],
"test:ci": [
"@test:unit:ci",
"@test:db:ci",
"@test:db",
"@test:api:ci"
],
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
@@ -121,11 +121,6 @@
"@test:db:postgres",
"@test:db:ms"
],
"test:db:ci": [
"@test:db:sqlite",
"@test:db:mysql",
"@test:db:postgres"
],
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml",
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
@@ -152,8 +147,7 @@
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>",
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB and PostgreSQL</>",
"test:db:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL and PostgreSQL</>",
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",

View File

@@ -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,
],
],

View File

@@ -8,30 +8,35 @@ 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\ShortCodeLengthOption::class,
Option\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
Option\Mercure\MercurePublicUrlConfigOption::class,
Option\Mercure\MercureInternalUrlConfigOption::class,
Option\Mercure\MercureJwtSecretConfigOption::class,
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\IpAnonymizationConfigOption::class,
],
'installation_commands' => [

View 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,
],
],
],
];

View 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',
],
];

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
return [
'mezzio-swoole' => [
'enable_coroutine' => true,
// Setting this to true can have unexpected behaviors when running several concurrent slow DB queries
'enable_coroutine' => false,
'swoole-http-server' => [
'host' => '0.0.0.0',

View File

@@ -12,6 +12,7 @@ return [
'hostname' => '',
],
'validate_url' => false,
'anonymize_remote_addr' => true,
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
],

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\ConfigAggregator;
use Laminas\ZendFrameworkBridge;
use Mezzio;
use Mezzio\ProblemDetails;
@@ -30,7 +29,6 @@ return (new ConfigAggregator\ConfigAggregator([
? 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,

View File

@@ -20,6 +20,7 @@ $buildDbConnection = function (): array {
$driver = env('DB_DRIVER', 'sqlite');
$isCi = env('TRAVIS', false);
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
$driverConfigMap = [
'sqlite' => [
@@ -29,19 +30,22 @@ $buildDbConnection = function (): array {
'mysql' => [
'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
'user' => 'root',
'password' => $isCi ? '' : 'root',
'password' => 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
'driverOptions' => [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
],
],
'postgres' => [
'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
'port' => $isCi ? '5433' : '5432',
'user' => 'postgres',
'password' => $isCi ? '' : 'root',
'password' => 'root',
'dbname' => 'shlink_test',
'charset' => 'utf8',
],
@@ -49,7 +53,7 @@ $buildDbConnection = function (): array {
'driver' => 'pdo_sqlsrv',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms',
'user' => 'sa',
'password' => $isCi ? '' : 'Passw0rd!',
'password' => 'Passw0rd!',
'dbname' => 'shlink_test',
],
];
@@ -79,13 +83,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([

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -ex
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
apt-get update
ACCEPT_EULA=Y apt-get install msodbcsql17
apt-get install unixodbc-dev

View 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;
}
}

View File

@@ -1,4 +1,4 @@
FROM php:7.4.2-fpm-alpine3.11
FROM php:7.4.5-fpm-alpine3.11
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18
@@ -67,15 +67,12 @@ 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
rm msodbcsql17_17.5.1.1-1_amd64.apk
# Install composer
RUN php -r "readfile('https://getcomposer.org/installer');" | php

View File

@@ -1,10 +1,10 @@
FROM php:7.4.2-alpine3.11
FROM php:7.4.5-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.15
ENV SWOOLE_VERSION 4.4.18
RUN apk update
@@ -68,15 +68,12 @@ RUN rm /tmp/inotify.tar.gz
# 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
rm msodbcsql17_17.5.1.1-1_amd64.apk
# Install composer
RUN php -r "readfile('https://getcomposer.org/installer');" | php

View File

@@ -0,0 +1,14 @@
server {
listen 80 default_server;
error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log;
location / {
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://shlink_swoole:8080;
proxy_read_timeout 90s;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20200503170404 extends AbstractMigration
{
private const INDEX_NAME = 'IDX_visits_date';
public function up(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf($visits->hasIndex(self::INDEX_NAME));
$visits->addIndex(['date'], self::INDEX_NAME);
}
public function down(Schema $schema): void
{
$visits = $schema->getTable('visits');
$this->skipIf(! $visits->hasIndex(self::INDEX_NAME));
$visits->dropIndex(self::INDEX_NAME);
}
}

14
docker-compose.ci.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3'
services:
shlink_db:
environment:
MYSQL_DATABASE: shlink_test
shlink_db_postgres:
environment:
POSTGRES_DB: shlink_test
shlink_db_maria:
environment:
MYSQL_DATABASE: shlink_test

View File

@@ -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:
@@ -27,9 +27,22 @@ services:
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_mercure
- shlink_mercure_proxy
environment:
LC_ALL: C
shlink_swoole_proxy:
container_name: shlink_swoole_proxy
image: nginx:1.17.10-alpine
ports:
- "8002:80"
volumes:
- ./:/home/shlink/www
- ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
links:
- shlink_swoole
shlink_swoole:
container_name: shlink_swoole
build:
@@ -47,6 +60,8 @@ services:
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_mercure
- shlink_mercure_proxy
environment:
LC_ALL: C
@@ -64,7 +79,7 @@ services:
shlink_db_postgres:
container_name: shlink_db_postgres
image: postgres:10.7-alpine
image: postgres:12.2-alpine
ports:
- "5433:5432"
volumes:
@@ -77,7 +92,7 @@ services:
shlink_db_maria:
container_name: shlink_db_maria
image: mariadb:10.2
image: mariadb:10.5
ports:
- "3308:3306"
volumes:
@@ -99,6 +114,27 @@ services:
shlink_redis:
container_name: shlink_redis
image: redis:5.0-alpine
image: redis:6.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.9
ports:
- "3080:80"
environment:
CORS_ALLOWED_ORIGINS: "*"
JWT_KEY: "mercure_jwt_key"
USE_FORWARDED_HEADERS: "1"

View File

@@ -73,18 +73,73 @@ It is possible to use a set of env vars to make this shlink instance interact wi
Taking this into account, you could run shlink on a local docker service like this:
```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.
@@ -113,15 +168,12 @@ This is the complete list of supported env vars:
* `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.
* `GEOLITE_LICENSE_KEY`: The license key used to download new GeoLite2 database files. This is not mandatory, as a default license key is provided, but it is **strongly recommended** that you provide your own. Go to [https://shlink.io/documentation/geolite-license-key](https://shlink.io/documentation/geolite-license-key) to know how to generate it.
* `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).
* `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.
* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations.
An example using all env vars could look like this:
@@ -150,6 +202,10 @@ docker run \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
-e DEFAULT_SHORT_CODES_LENGTH=6 \
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
-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 \
-e ANONYMIZE_REMOTE_ADDR=false \
shlinkio/shlink:stable
```
@@ -191,7 +247,11 @@ The whole configuration should have this format, but it can be split into multip
"host": "something.rds.amazonaws.com",
"port": "3306"
},
"geolite_license_key": "kjh23ljkbndskj345"
"geolite_license_key": "kjh23ljkbndskj345",
"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",
"anonymize_remote_addr": false
}
```

View File

@@ -7,7 +7,9 @@ echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
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
# Push stable tag only if this is not an alpha or beta tag
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && 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 .

View File

@@ -41,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],
@@ -79,6 +81,17 @@ $helper = new class {
$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 [
@@ -104,6 +117,7 @@ return [
'hostname' => env('SHORT_DOMAIN_HOST', ''),
],
'validate_url' => (bool) env('VALIDATE_URLS', false),
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
],
@@ -151,4 +165,6 @@ return [
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
],
'mercure' => $helper->getMercureConfig(),
];

View 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"
}
}
}
}
}
}

View 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"
}
}
}

View File

@@ -0,0 +1,17 @@
{
"type": "object",
"properties": {
"tag": {
"type": "string",
"description": "The unique tag name"
},
"shortUrlsCount": {
"type": "number",
"description": "The amount of short URLs using this tag"
},
"userAgent": {
"type": "number",
"description": "The combined amount of visits received by short URLs with this tag"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"type": "object",
"required": ["visitsCount"],
"properties": {
"visitsCount": {
"type": "number",
"description": "The total amount of visits received."
}
}
}

View File

@@ -14,6 +14,19 @@
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "withStats",
"description": "Whether you want to include also a list with general stats by tag or not.",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"true",
"false"
]
}
}
],
"responses": {
@@ -26,12 +39,20 @@
"properties": {
"tags": {
"type": "object",
"required": ["data"],
"properties": {
"data": {
"type": "array",
"items": {
"type": "string"
}
},
"stats": {
"description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
"type": "array",
"items": {
"$ref": "../definitions/TagInfo.json"
}
}
}
}

View 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"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,154 @@
{
"get": {
"operationId": "getTagVisits",
"tags": [
"Visits"
],
"summary": "List visits for tag",
"description": "Get the list of visits on any short URL which is tagged with provided tag.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "tag",
"in": "path",
"description": "The tag from which we want to get the visits.",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "startDate",
"in": "query",
"description": "The date (in ISO-8601 format) from which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "endDate",
"in": "query",
"description": "The date (in ISO-8601 format) until which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "page",
"in": "query",
"description": "The page to display. Defaults to 1",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "itemsPerPage",
"in": "query",
"description": "The amount of items to return on every page. Defaults to all the items",
"required": false,
"schema": {
"type": "number"
}
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "List of visits.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"visits": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/Visit.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
}
}
}
}
}
},
"examples": {
"application/json": {
"visits": {
"data": [
{
"referer": "https://twitter.com",
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
"visitLocation": null
},
{
"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"
}
},
{
"referer": null,
"date": "2015-08-20T05:05:03+04:00",
"userAgent": "some_web_crawler/1.4",
"visitLocation": null
}
],
"pagination": {
"currentPage": 5,
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}
}
},
"404": {
"description": "The tag does not exist.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,54 @@
{
"get": {
"operationId": "getGlobalVisits",
"tags": [
"Visits"
],
"summary": "Get general visits stats",
"description": "Get general visits stats not linked to one specific short URL.",
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "Visits stats.",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"visits": {
"$ref": "../definitions/VisitStats.json"
}
}
}
}
},
"examples": {
"application/json": {
"visits": {
"visitsCount": 1569874
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View File

@@ -78,9 +78,19 @@
"$ref": "paths/v1_tags.json"
},
"/rest/v{version}/visits": {
"$ref": "paths/v2_visits.json"
},
"/rest/v{version}/short-urls/{shortCode}/visits": {
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
},
"/rest/v{version}/tags/{tag}/visits": {
"$ref": "paths/v2_tags_{tag}_visits.json"
},
"/rest/v{version}/mercure-info": {
"$ref": "paths/v2_mercure-info.json"
},
"/rest/health": {
"$ref": "paths/health.json"

View File

@@ -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\Tag\TagService;
use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
@@ -78,10 +79,10 @@ return [
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class],
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class],
Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\CreateTagCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;

View File

@@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@@ -35,17 +35,20 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows());
ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return ExitCodes::EXIT_SUCCESS;
}
private function getTagsRows(): array
{
$tags = $this->tagService->listTags();
$tags = $this->tagService->tagsInfo();
if (empty($tags)) {
return [['No tags yet']];
return [['No tags found', '-', '-']];
}
return map($tags, fn (Tag $tag) => [(string) $tag]);
return map(
$tags,
fn (TagInfo $tagInfo) => [(string) $tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
);
}
}

View File

@@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;

View File

@@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -8,7 +8,8 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -31,28 +32,32 @@ class ListTagsCommandTest extends TestCase
/** @test */
public function noTagsPrintsEmptyMessage(): void
{
$listTags = $this->tagService->listTags()->willReturn([]);
$tagsInfo = $this->tagService->tagsInfo()->willReturn([]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('No tags yet', $output);
$listTags->shouldHaveBeenCalled();
$this->assertStringContainsString('No tags found', $output);
$tagsInfo->shouldHaveBeenCalled();
}
/** @test */
public function listOfTagsIsPrinted(): void
{
$listTags = $this->tagService->listTags()->willReturn([
new Tag('foo'),
new Tag('bar'),
$tagsInfo = $this->tagService->tagsInfo()->willReturn([
new TagInfo(new Tag('foo'), 10, 2),
new TagInfo(new Tag('bar'), 7, 32),
]);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('foo', $output);
$this->assertStringContainsString('bar', $output);
$listTags->shouldHaveBeenCalled();
$this->assertStringContainsString('| foo', $output);
$this->assertStringContainsString('| bar', $output);
$this->assertStringContainsString('| 10 ', $output);
$this->assertStringContainsString('| 2 ', $output);
$this->assertStringContainsString('| 7 ', $output);
$this->assertStringContainsString('| 32 ', $output);
$tagsInfo->shouldHaveBeenCalled();
}
}

View File

@@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -27,7 +27,8 @@ return [
Service\VisitsTracker::class => ConfigAbstractFactory::class,
Service\ShortUrlService::class => ConfigAbstractFactory::class,
Visit\VisitLocator::class => ConfigAbstractFactory::class,
Service\Tag\TagService::class => ConfigAbstractFactory::class,
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
Tag\TagService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
@@ -38,6 +39,8 @@ return [
Action\QrCodeAction::class => ConfigAbstractFactory::class,
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
],
],
@@ -51,10 +54,15 @@ return [
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
Service\VisitsTracker::class => [
'em',
EventDispatcherInterface::class,
'config.url_shortener.anonymize_remote_addr',
],
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
Visit\VisitLocator::class => ['em'],
Service\Tag\TagService::class => ['em'],
Visit\VisitsStatsHelper::class => ['em'],
Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => [
'em',
Options\DeleteShortUrlsOptions::class,
@@ -83,6 +91,8 @@ return [
],
Resolver\PersistenceDomainResolver::class => ['em'],
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
],
];

View File

@@ -60,6 +60,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
->setOrderBy(['name' => 'ASC'])
->build();
$builder->createManyToOne('domain', Entity\Domain::class)

View File

@@ -24,4 +24,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
$builder->createField('name', Types::STRING)
->unique()
->build();
$builder->addInverseManyToMany('shortUrls', Entity\ShortUrl::class, 'tags');
};

View File

@@ -32,6 +32,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->columnName('`date`')
->build();
$builder->addIndex(['date'], 'IDX_visits_date');
$builder->createField('remoteAddr', Types::STRING)
->columnName('remote_addr')
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)

View File

@@ -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',
],
],
];

View File

@@ -34,6 +34,10 @@ class SimplifiedConfigParser
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
'geolite_license_key' => ['geolite2', 'license_key'],
'mercure_public_hub_url' => ['mercure', 'public_hub_url'],
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'],
];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [

View File

@@ -4,16 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\Common\Collections;
use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Tag extends AbstractEntity implements JsonSerializable
{
private string $name;
private Collections\Collection $shortUrls;
public function __construct(string $name)
{
$this->name = $name;
$this->shortUrls = new Collections\ArrayCollection();
}
public function rename(string $name): void

View File

@@ -21,24 +21,24 @@ class Visit extends AbstractEntity implements JsonSerializable
private ShortUrl $shortUrl;
private ?VisitLocation $visitLocation = null;
public function __construct(ShortUrl $shortUrl, Visitor $visitor, ?Chronos $date = null)
public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null)
{
$this->shortUrl = $shortUrl;
$this->date = $date ?? Chronos::now();
$this->userAgent = $visitor->getUserAgent();
$this->referer = $visitor->getReferer();
$this->remoteAddr = $this->obfuscateAddress($visitor->getRemoteAddress());
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
}
private function obfuscateAddress(?string $address): ?string
private function processAddress(bool $anonymize, ?string $address): ?string
{
// Localhost addresses do not need to be obfuscated
if ($address === null || $address === IpAddress::LOCALHOST) {
// Localhost addresses do not need to be anonymized
if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) {
return $address;
}
try {
return (string) IpAddress::fromString($address)->getObfuscatedCopy();
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) {
return null;
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -9,7 +9,6 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManager;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
@@ -42,35 +41,22 @@ class LocateShortUrlVisit
public function __invoke(ShortUrlVisited $shortUrlVisited): void
{
// FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717
// Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented
if ($this->em instanceof ReopeningEntityManager) {
$this->em->open();
}
$visitId = $shortUrlVisited->visitId();
try {
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
}
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
} finally {
// FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717
// Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented
$this->em->getConnection()->close();
$this->em->clear();
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
}
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
}
private function downloadOrUpdateGeoLiteDb(string $visitId): bool

View 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,
]);
}
}
}

View File

@@ -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

View 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);
}
}

View 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;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Laminas\Paginator\Adapter\AdapterInterface;
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
{
private ?int $count = null;
final public function count(): int
{
// Since a new adapter instance is created every time visits are fetched, it is reasonably safe to internally
// cache the count value.
// The reason it is cached is because the Paginator is actually calling the method twice.
// An inconsistent value could be returned if between the first call and the second one, a new visit is created.
// However, it's almost instant, and then the adapter instance is discarded immediately after.
if ($this->count !== null) {
return $this->count;
}
return $this->count = $this->doCount();
}
abstract protected function doCount(): int;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
private VisitRepositoryInterface $visitRepository;
private string $tag;
private VisitsParams $params;
public function __construct(VisitRepositoryInterface $visitRepository, string $tag, VisitsParams $params)
{
$this->visitRepository = $visitRepository;
$this->params = $params;
$this->tag = $tag;
}
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
{
return $this->visitRepository->findVisitsByTag(
$this->tag,
$this->params->getDateRange(),
$itemCountPerPage,
$offset,
);
}
protected function doCount(): int
{
return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange());
}
}

View File

@@ -4,12 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Laminas\Paginator\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
class VisitsPaginatorAdapter implements AdapterInterface
class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
private VisitRepositoryInterface $visitRepository;
private ShortUrlIdentifier $identifier;
@@ -36,7 +35,7 @@ class VisitsPaginatorAdapter implements AdapterInterface
);
}
public function count(): int
protected function doCount(): int
{
return $this->visitRepository->countVisitsByShortCode(
$this->identifier->shortCode(),

View File

@@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use function Functional\map;
class TagRepository extends EntityRepository implements TagRepositoryInterface
{
@@ -21,4 +24,25 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
return $qb->getQuery()->execute();
}
/**
* @return TagInfo[]
*/
public function findTagsWithInfo(): array
{
$dql = <<<DQL
SELECT t AS tag, COUNT(DISTINCT s.id) AS shortUrlsCount, COUNT(DISTINCT v.id) AS visitsCount
FROM Shlinkio\Shlink\Core\Entity\Tag t
LEFT JOIN t.shortUrls s
LEFT JOIN s.visits v
GROUP BY t
ORDER BY t.name ASC
DQL;
$query = $this->getEntityManager()->createQuery($dql);
return map(
$query->getResult(),
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
);
}
}

View File

@@ -5,8 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
interface TagRepositoryInterface extends ObjectRepository
{
public function deleteByName(array $names): int;
/**
* @return TagInfo[]
*/
public function findTagsWithInfo(): array;
}

View File

@@ -5,9 +5,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use function array_column;
use const PHP_INT_MAX;
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
{
@@ -21,7 +28,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
->from(Visit::class, 'v')
->where($qb->expr()->isNull('v.visitLocation'));
return $this->findVisitsForQuery($qb, $blockSize);
return $this->visitsIterableForQuery($qb, $blockSize);
}
/**
@@ -37,7 +44,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
->setParameter('isEmpty', true);
return $this->findVisitsForQuery($qb, $blockSize);
return $this->visitsIterableForQuery($qb, $blockSize);
}
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
@@ -46,10 +53,10 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
$qb->select('v')
->from(Visit::class, 'v');
return $this->findVisitsForQuery($qb, $blockSize);
return $this->visitsIterableForQuery($qb, $blockSize);
}
private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable
private function visitsIterableForQuery(QueryBuilder $qb, int $blockSize): iterable
{
$originalQueryBuilder = $qb->setMaxResults($blockSize)
->orderBy('v.id', 'ASC');
@@ -82,23 +89,13 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
?int $offset = null
): array {
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
$qb->select('v')
->orderBy('v.date', 'DESC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $qb->getQuery()->getResult();
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
}
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
{
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
$qb->select('COUNT(DISTINCT v.id)');
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
@@ -108,31 +105,103 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
?string $domain,
?DateRange $dateRange
): QueryBuilder {
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOne($shortCode, $domain);
$shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1;
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
// Since they are not strictly provided by the caller, it's reasonably safe
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 'su')
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
->setParameter('shortCode', $shortCode);
// Apply domain filtering
if ($domain !== null) {
$qb->join('su.domain', 'd')
->andWhere($qb->expr()->eq('d.authority', ':domain'))
->setParameter('domain', $domain);
} else {
$qb->andWhere($qb->expr()->isNull('su.domain'));
}
->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
// Apply date range filtering
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', ':startDate'))
->setParameter('startDate', $dateRange->getStartDate());
}
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', ':endDate'))
->setParameter('endDate', $dateRange->getEndDate());
}
$this->applyDatesInline($qb, $dateRange);
return $qb;
}
public function findVisitsByTag(
string $tag,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
): array {
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
}
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int
{
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
$qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('s.id')
->from(ShortUrl::class, 's')
->join('s.tags', 't')
->where($qb->expr()->eq('t.name', ':tag'))
->setParameter('tag', $tag);
$shortUrlIds = array_column($qb->getQuery()->getArrayResult(), 'id');
$shortUrlIds[] = '-1'; // Add an invalid ID, in case the list is empty
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
// Since they are not strictly provided by the caller, it's reasonably safe
$qb2 = $this->getEntityManager()->createQueryBuilder();
$qb2->from(Visit::class, 'v')
->where($qb2->expr()->in('v.shortUrl', $shortUrlIds));
// Apply date range filtering
$this->applyDatesInline($qb2, $dateRange);
return $qb2;
}
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
{
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
}
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
}
}
private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array
{
$qb->select('v.id')
->orderBy('v.id', 'DESC')
// Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing
// order on sub-queries without offset
->setMaxResults($limit ?? PHP_INT_MAX)
->setFirstResult($offset ?? 0);
$subQuery = $qb->getQuery()->getSQL();
// A native query builder needs to be used here because DQL and ORM query builders do not accept
// sub-queries at "from" and "join" level.
// If no sub-query is used, then performance drops dramatically while the "offset" grows.
$nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
$nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*')
->from('visits', 'v')
->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id'))
->orderBy('v.id', 'DESC');
$rsm = new ResultSetMappingBuilder($this->getEntityManager());
$rsm->addRootEntityFromClassMetadata(Visit::class, 'v', ['id' => 'visit_id']);
$rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [
'id' => 'visit_location_id',
]);
$query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm);
return $query->getResult();
}
}

View File

@@ -43,4 +43,16 @@ interface VisitRepositoryInterface extends ObjectRepository
?string $domain = null,
?DateRange $dateRange = null
): int;
/**
* @return Visit[]
*/
public function findVisitsByTag(
string $tag,
?DateRange $dateRange = null,
?int $limit = null,
?int $offset = null
): array;
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int;
}

View File

@@ -8,33 +8,39 @@ use Doctrine\ORM;
use Laminas\Paginator\Paginator;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
class VisitsTracker implements VisitsTrackerInterface
{
private ORM\EntityManagerInterface $em;
private EventDispatcherInterface $eventDispatcher;
private bool $anonymizeRemoteAddr;
public function __construct(ORM\EntityManagerInterface $em, EventDispatcherInterface $eventDispatcher)
{
public function __construct(
ORM\EntityManagerInterface $em,
EventDispatcherInterface $eventDispatcher,
bool $anonymizeRemoteAddr
) {
$this->em = $em;
$this->eventDispatcher = $eventDispatcher;
$this->anonymizeRemoteAddr = $anonymizeRemoteAddr;
}
/**
* Tracks a new visit to provided short code from provided visitor
*/
public function track(ShortUrl $shortUrl, Visitor $visitor): void
{
$visit = new Visit($shortUrl, $visitor);
$visit = new Visit($shortUrl, $visitor, $this->anonymizeRemoteAddr);
$this->em->persist($visit);
$this->em->flush();
@@ -43,8 +49,6 @@ class VisitsTracker implements VisitsTrackerInterface
}
/**
* Returns the visits on certain short code
*
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
@@ -56,7 +60,7 @@ class VisitsTracker implements VisitsTrackerInterface
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
/** @var VisitRepository $repo */
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
$paginator->setItemCountPerPage($params->getItemsPerPage())
@@ -64,4 +68,26 @@ class VisitsTracker implements VisitsTrackerInterface
return $paginator;
}
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params): Paginator
{
/** @var TagRepository $tagRepo */
$tagRepo = $this->em->getRepository(Tag::class);
$count = $tagRepo->count(['name' => $tag]);
if ($count === 0) {
throw TagNotFoundException::fromTag($tag);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params));
$paginator->setItemCountPerPage($params->getItemsPerPage())
->setCurrentPageNumber($params->getPage());
return $paginator;
}
}

View File

@@ -8,22 +8,24 @@ use Laminas\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
interface VisitsTrackerInterface
{
/**
* Tracks a new visit to provided short code from provided visitor
*/
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
/**
* Returns the visits on certain short code
*
* @return Visit[]|Paginator
* @throws ShortUrlNotFoundException
*/
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator;
/**
* @return Visit[]|Paginator
* @throws TagNotFoundException
*/
public function visitsForTag(string $tag, VisitsParams $params): Paginator;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Model;
use JsonSerializable;
use Shlinkio\Shlink\Core\Entity\Tag;
final class TagInfo implements JsonSerializable
{
private Tag $tag;
private int $shortUrlsCount;
private int $visitsCount;
public function __construct(Tag $tag, int $shortUrlsCount, int $visitsCount)
{
$this->tag = $tag;
$this->shortUrlsCount = $shortUrlsCount;
$this->visitsCount = $visitsCount;
}
public function tag(): Tag
{
return $this->tag;
}
public function shortUrlsCount(): int
{
return $this->shortUrlsCount;
}
public function visitsCount(): int
{
return $this->visitsCount;
}
public function jsonSerialize(): array
{
return [
'tag' => $this->tag,
'shortUrlsCount' => $this->shortUrlsCount,
'visitsCount' => $this->visitsCount,
];
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\Tag;
namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM;
@@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
class TagService implements TagServiceInterface
@@ -25,7 +27,6 @@ class TagService implements TagServiceInterface
/**
* @return Tag[]
* @throws \UnexpectedValueException
*/
public function listTags(): array
{
@@ -34,6 +35,16 @@ class TagService implements TagServiceInterface
return $tags;
}
/**
* @return TagInfo[]
*/
public function tagsInfo(): array
{
/** @var TagRepositoryInterface $repo */
$repo = $this->em->getRepository(Tag::class);
return $repo->findTagsWithInfo();
}
/**
* @param string[] $tagNames
*/

View File

@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\Tag;
namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
interface TagServiceInterface
{
@@ -16,6 +17,11 @@ interface TagServiceInterface
*/
public function listTags(): array;
/**
* @return TagInfo[]
*/
public function tagsInfo(): array;
/**
* @param string[] $tagNames
*/

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Model;
use JsonSerializable;
final class VisitsStats implements JsonSerializable
{
private int $visitsCount;
public function __construct(int $visitsCount)
{
$this->visitsCount = $visitsCount;
}
public function jsonSerialize(): array
{
return [
'visitsCount' => $this->visitsCount,
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
class VisitsStatsHelper implements VisitsStatsHelperInterface
{
private EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
public function getVisitsStats(): VisitsStats
{
return new VisitsStats($this->getVisitsCount());
}
private function getVisitsCount(): int
{
/** @var VisitRepository $visitsRepo */
$visitsRepo = $this->em->getRepository(Visit::class);
return $visitsRepo->count([]);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
interface VisitsStatsHelperInterface
{
public function getVisitsStats(): VisitsStats;
}

View File

@@ -4,13 +4,21 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository;
use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_chunk;
class TagRepositoryTest extends DatabaseTestCase
{
protected const ENTITIES_TO_EMPTY = [
Visit::class,
ShortUrl::class,
Tag::class,
];
@@ -40,4 +48,53 @@ class TagRepositoryTest extends DatabaseTestCase
$this->assertEquals(2, $this->repo->deleteByName($toDelete));
}
/** @test */
public function properTagsInfoIsReturned(): void
{
$names = ['foo', 'bar', 'baz', 'another'];
$tags = [];
foreach ($names as $name) {
$tag = new Tag($name);
$tags[] = $tag;
$this->getEntityManager()->persist($tag);
}
[$firstUrlTags] = array_chunk($tags, 3);
$secondUrlTags = [$tags[0]];
$shortUrl = new ShortUrl('');
$shortUrl->setTags(new ArrayCollection($firstUrlTags));
$this->getEntityManager()->persist($shortUrl);
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
$shortUrl2 = new ShortUrl('');
$shortUrl2->setTags(new ArrayCollection($secondUrlTags));
$this->getEntityManager()->persist($shortUrl2);
$this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance()));
$this->getEntityManager()->flush();
$result = $this->repo->findTagsWithInfo();
$this->assertCount(4, $result);
$this->assertEquals(
['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0],
$result[0]->jsonSerialize(),
);
$this->assertEquals(
['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3],
$result[1]->jsonSerialize(),
);
$this->assertEquals(
['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3],
$result[2]->jsonSerialize(),
);
$this->assertEquals(
['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4],
$result[3]->jsonSerialize(),
);
}
}

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
@@ -27,6 +29,7 @@ class VisitRepositoryTest extends DatabaseTestCase
Visit::class,
ShortUrl::class,
Domain::class,
Tag::class,
];
private VisitRepository $repo;
@@ -125,33 +128,99 @@ class VisitRepositoryTest extends DatabaseTestCase
)));
}
private function createShortUrlsAndVisits(): array
/** @test */
public function findVisitsByTagReturnsProperData(): void
{
$foo = new Tag('foo');
$this->getEntityManager()->persist($foo);
/** @var ShortUrl $shortUrl */
[,, $shortUrl] = $this->createShortUrlsAndVisits(false);
/** @var ShortUrl $shortUrl2 */
[,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
/** @var ShortUrl $shortUrl3 */
[,, $shortUrl3] = $this->createShortUrlsAndVisits(false);
$shortUrl->setTags(new ArrayCollection([$foo]));
$shortUrl2->setTags(new ArrayCollection([$foo]));
$shortUrl3->setTags(new ArrayCollection([$foo]));
$this->getEntityManager()->flush();
$this->assertCount(0, $this->repo->findVisitsByTag('invalid'));
$this->assertCount(18, $this->repo->findVisitsByTag((string) $foo));
$this->assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-03'),
)));
}
/** @test */
public function countVisitsByTagReturnsProperData(): void
{
$foo = new Tag('foo');
$this->getEntityManager()->persist($foo);
/** @var ShortUrl $shortUrl */
[,, $shortUrl] = $this->createShortUrlsAndVisits(false);
/** @var ShortUrl $shortUrl2 */
[,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
$shortUrl->setTags(new ArrayCollection([$foo]));
$shortUrl2->setTags(new ArrayCollection([$foo]));
$this->getEntityManager()->flush();
$this->assertEquals(0, $this->repo->countVisitsByTag('invalid'));
$this->assertEquals(12, $this->repo->countVisitsByTag((string) $foo));
$this->assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-02'),
Chronos::parse('2016-01-03'),
)));
$this->assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange(
Chronos::parse('2016-01-03'),
)));
}
private function createShortUrlsAndVisits(bool $withDomain = true): array
{
$shortUrl = new ShortUrl('');
$domain = 'example.com';
$shortCode = $shortUrl->getShortCode();
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
'customSlug' => $shortCode,
'domain' => $domain,
]));
$this->getEntityManager()->persist($shortUrl);
$this->getEntityManager()->persist($shortUrlWithDomain);
for ($i = 0; $i < 6; $i++) {
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
$this->getEntityManager()->persist($visit);
}
for ($i = 0; $i < 3; $i++) {
$visit = new Visit(
$shortUrlWithDomain,
$shortUrl,
Visitor::emptyInstance(),
true,
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
);
$this->getEntityManager()->persist($visit);
}
$this->getEntityManager()->flush();
return [$shortCode, $domain];
if ($withDomain) {
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
'customSlug' => $shortCode,
'domain' => $domain,
]));
$this->getEntityManager()->persist($shortUrlWithDomain);
for ($i = 0; $i < 3; $i++) {
$visit = new Visit(
$shortUrlWithDomain,
Visitor::emptyInstance(),
true,
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
);
$this->getEntityManager()->persist($visit);
}
$this->getEntityManager()->flush();
}
return [$shortCode, $domain, $shortUrl];
}
}

View File

@@ -61,6 +61,10 @@ class SimplifiedConfigParserTest extends TestCase
],
'default_short_codes_length' => 8,
'geolite_license_key' => 'kjh23ljkbndskj345',
'mercure_public_hub_url' => 'public_url',
'mercure_internal_hub_url' => 'internal_url',
'mercure_jwt_secret' => 'super_secret_value',
'anonymize_remote_addr' => false,
];
$expected = [
'app_options' => [
@@ -89,6 +93,7 @@ class SimplifiedConfigParserTest extends TestCase
'https://third-party.io/foo',
],
'default_short_codes_length' => 8,
'anonymize_remote_addr' => false,
],
'delete_short_urls' => [
@@ -132,6 +137,12 @@ class SimplifiedConfigParserTest extends TestCase
'geolite2' => [
'license_key' => 'kjh23ljkbndskj345',
],
'mercure' => [
'public_hub_url' => 'public_url',
'internal_hub_url' => 'internal_url',
'jwt_secret' => 'super_secret_value',
],
];
$result = ($this->postProcessor)(array_merge($config, $simplified));

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Entity;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
@@ -18,7 +19,7 @@ class VisitTest extends TestCase
*/
public function isProperlyJsonSerialized(?Chronos $date): void
{
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), $date);
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date);
$this->assertEquals([
'referer' => 'some site',
@@ -33,4 +34,25 @@ class VisitTest extends TestCase
yield 'null date' => [null];
yield 'not null date' => [Chronos::now()->subDays(10)];
}
/**
* @test
* @dataProvider provideAddresses
*/
public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void
{
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', $address), $anonymize);
$this->assertEquals($expectedAddress, $visit->getRemoteAddr());
}
public function provideAddresses(): iterable
{
yield 'anonymized null address' => [true, null, null];
yield 'non-anonymized null address' => [false, null, null];
yield 'anonymized localhost' => [true, IpAddress::LOCALHOST, IpAddress::LOCALHOST];
yield 'non-anonymized localhost' => [false, IpAddress::LOCALHOST, IpAddress::LOCALHOST];
yield 'anonymized regular address' => [true, '1.2.3.4', '1.2.3.0'];
yield 'non-anonymized regular address' => [false, '1.2.3.4', '1.2.3.4'];
}
}

View File

@@ -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();
}
}

View File

@@ -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];
})();
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -38,10 +37,6 @@ class LocateShortUrlVisitTest extends TestCase
{
$this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$conn = $this->prophesize(Connection::class);
$this->em->getConnection()->willReturn($conn->reveal());
$this->em->clear()->will(function (): void {
});
$this->logger = $this->prophesize(LoggerInterface::class);
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);

View 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();
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Exception;

View 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'];
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
class VisitsForTagPaginatorAdapterTest extends TestCase
{
private VisitsForTagPaginatorAdapter $adapter;
private ObjectProphecy $repo;
protected function setUp(): void
{
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
$this->adapter = new VisitsForTagPaginatorAdapter($this->repo->reveal(), 'foo', VisitsParams::fromRawData([]));
}
/** @test */
public function repoIsCalledEveryTimeItemsAreFetched(): void
{
$count = 3;
$limit = 1;
$offset = 5;
$findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]);
for ($i = 0; $i < $count; $i++) {
$this->adapter->getItems($offset, $limit);
}
$findVisits->shouldHaveBeenCalledTimes($count);
}
/** @test */
public function repoIsCalledOnlyOnceForCount(): void
{
$count = 3;
$countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->willReturn(3);
for ($i = 0; $i < $count; $i++) {
$this->adapter->count();
}
$countVisits->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
class VisitsPaginatorAdapterTest extends TestCase
{
private VisitsPaginatorAdapter $adapter;
private ObjectProphecy $repo;
protected function setUp(): void
{
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
$this->adapter = new VisitsPaginatorAdapter(
$this->repo->reveal(),
new ShortUrlIdentifier(''),
VisitsParams::fromRawData([]),
);
}
/** @test */
public function repoIsCalledEveryTimeItemsAreFetched(): void
{
$count = 3;
$limit = 1;
$offset = 5;
$findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]);
for ($i = 0; $i < $count; $i++) {
$this->adapter->getItems($offset, $limit);
}
$findVisits->shouldHaveBeenCalledTimes($count);
}
/** @test */
public function repoIsCalledOnlyOnceForCount(): void
{
$count = 3;
$countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->willReturn(3);
for ($i = 0; $i < $count; $i++) {
$this->adapter->count();
}
$countVisits->shouldHaveBeenCalledOnce();
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Service\Tag;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
@@ -13,16 +12,21 @@ use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagService;
class TagServiceTest extends TestCase
{
private TagService $service;
private ObjectProphecy $em;
private ObjectProphecy $repo;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->repo = $this->prophesize(TagRepository::class);
$this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled();
$this->service = new TagService($this->em->reveal());
}
@@ -31,36 +35,41 @@ class TagServiceTest extends TestCase
{
$expected = [new Tag('foo'), new Tag('bar')];
$repo = $this->prophesize(EntityRepository::class);
$find = $repo->findBy(Argument::cetera())->willReturn($expected);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find = $this->repo->findBy(Argument::cetera())->willReturn($expected);
$result = $this->service->listTags();
$this->assertEquals($expected, $result);
$find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
}
/** @test */
public function tagsInfoDelegatesOnRepository(): void
{
$expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
$find = $this->repo->findTagsWithInfo()->willReturn($expected);
$result = $this->service->tagsInfo();
$this->assertEquals($expected, $result);
$find->shouldHaveBeenCalled();
}
/** @test */
public function deleteTagsDelegatesOnRepository(): void
{
$repo = $this->prophesize(TagRepository::class);
$delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4);
$this->service->deleteTags(['foo', 'bar']);
$delete->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
}
/** @test */
public function createTagsPersistsEntities(): void
{
$repo = $this->prophesize(TagRepository::class);
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null);
@@ -68,7 +77,6 @@ class TagServiceTest extends TestCase
$this->assertCount(2, $result);
$find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$persist->shouldHaveBeenCalledTimes(2);
$flush->shouldHaveBeenCalled();
}
@@ -76,12 +84,9 @@ class TagServiceTest extends TestCase
/** @test */
public function renameInvalidTagThrowsException(): void
{
$repo = $this->prophesize(TagRepository::class);
$find = $repo->findOneBy(Argument::cetera())->willReturn(null);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(null);
$find->shouldBeCalled();
$getRepo->shouldBeCalled();
$this->expectException(TagNotFoundException::class);
$this->service->renameTag('foo', 'bar');
@@ -95,10 +100,8 @@ class TagServiceTest extends TestCase
{
$expected = new Tag('foo');
$repo = $this->prophesize(TagRepository::class);
$find = $repo->findOneBy(Argument::cetera())->willReturn($expected);
$countTags = $repo->count(Argument::cetera())->willReturn($count);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected);
$countTags = $this->repo->count(Argument::cetera())->willReturn($count);
$flush = $this->em->flush()->willReturn(null);
$tag = $this->service->renameTag($oldName, $newName);
@@ -106,7 +109,6 @@ class TagServiceTest extends TestCase
$this->assertSame($expected, $tag);
$this->assertEquals($newName, (string) $tag);
$find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled();
$countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1);
}
@@ -120,14 +122,11 @@ class TagServiceTest extends TestCase
/** @test */
public function renameTagToAnExistingNameThrowsException(): void
{
$repo = $this->prophesize(TagRepository::class);
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$countTags = $repo->count(Argument::cetera())->willReturn(1);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$countTags = $this->repo->count(Argument::cetera())->willReturn(1);
$flush = $this->em->flush(Argument::any())->willReturn(null);
$find->shouldBeCalled();
$getRepo->shouldBeCalled();
$countTags->shouldBeCalled();
$flush->shouldNotBeCalled();
$this->expectException(TagConflictException::class);

View File

@@ -6,20 +6,22 @@ namespace ShlinkioTest\Shlink\Core\Service;
use Doctrine\ORM\EntityManager;
use Laminas\Stdlib\ArrayUtils;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
@@ -37,7 +39,7 @@ class VisitsTrackerTest extends TestCase
$this->em = $this->prophesize(EntityManager::class);
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal());
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true);
}
/** @test */
@@ -53,25 +55,6 @@ class VisitsTrackerTest extends TestCase
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
}
/** @test */
public function trackedIpAddressGetsObfuscated(): void
{
$shortCode = '123ABC';
$this->em->persist(Argument::any())->will(function ($args) {
/** @var Visit $visit */
$visit = $args[0];
Assert::assertEquals('4.3.2.0', $visit->getRemoteAddr());
$visit->setId('1');
return $visit;
})->shouldBeCalledOnce();
$this->em->flush()->shouldBeCalledOnce();
$this->visitsTracker->track(new ShortUrl($shortCode), new Visitor('', '', '4.3.2.1'));
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
}
/** @test */
public function infoReturnsVisitsForCertainShortCode(): void
{
@@ -105,4 +88,40 @@ class VisitsTrackerTest extends TestCase
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
}
/** @test */
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
{
$tag = 'foo';
$repo = $this->prophesize(TagRepository::class);
$count = $repo->count(['name' => $tag])->willReturn(0);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$this->expectException(TagNotFoundException::class);
$count->shouldBeCalledOnce();
$getRepo->shouldBeCalledOnce();
$this->visitsTracker->visitsForTag($tag, new VisitsParams());
}
/** @test */
public function visitsForTagAreReturnedAsExpected(): void
{
$tag = 'foo';
$repo = $this->prophesize(TagRepository::class);
$count = $repo->count(['name' => $tag])->willReturn(1);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()));
$repo2 = $this->prophesize(VisitRepository::class);
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0)->willReturn($list);
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->willReturn(1);
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
$paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams());
$this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
$count->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Visit;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper;
use function Functional\map;
use function range;
class VisitsStatsHelperTest extends TestCase
{
private VisitsStatsHelper $helper;
private ObjectProphecy $em;
public function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->helper = new VisitsStatsHelper($this->em->reveal());
}
/**
* @test
* @dataProvider provideCounts
*/
public function returnsExpectedVisitsStats(int $expectedCount): void
{
$repo = $this->prophesize(VisitRepository::class);
$count = $repo->count([])->willReturn($expectedCount);
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
$stats = $this->helper->getVisitsStats();
$this->assertEquals(new VisitsStats($expectedCount), $stats);
$count->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
public function provideCounts(): iterable
{
return map(range(0, 50, 5), fn (int $value) => [$value]);
}
}

View File

@@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
use Doctrine\DBAL\Connection;
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
return [
@@ -20,6 +21,7 @@ return [
ApiKeyService::class => ConfigAbstractFactory::class,
Action\HealthAction::class => ConfigAbstractFactory::class,
Action\MercureInfoAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class,
@@ -27,7 +29,9 @@ return [
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
@@ -45,35 +49,29 @@ return [
ConfigAbstractFactory::class => [
ApiKeyService::class => ['em'],
Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'],
Action\ShortUrl\CreateShortUrlAction::class => [
Service\UrlShortener::class,
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\HealthAction::class => ['em', AppOptions::class],
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'],
Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Action\ShortUrl\SingleStepCreateShortUrlAction::class => [
Service\UrlShortener::class,
ApiKeyService::class,
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'],
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class],
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class],
Action\ShortUrl\ResolveShortUrlAction::class => [
Service\ShortUrl\ShortUrlResolver::class,
'config.url_shortener.domain',
],
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'],
Action\ShortUrl\ListShortUrlsAction::class => [
Service\ShortUrlService::class,
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class],
Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class],
Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],
Action\Tag\ListTagsAction::class => [TagService::class],
Action\Tag\DeleteTagsAction::class => [TagService::class],
Action\Tag\CreateTagsAction::class => [TagService::class],
Action\Tag\UpdateTagAction::class => [TagService::class],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [

View File

@@ -26,13 +26,17 @@ return [
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
// Visits
Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
// Tags
Action\Tag\ListTagsAction::getRouteDef(),
Action\Tag\DeleteTagsAction::getRouteDef(),
Action\Tag\CreateTagsAction::getRouteDef(),
Action\Tag\UpdateTagAction::getRouteDef(),
Action\MercureInfoAction::getRouteDef(),
],
];

View File

@@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action;
use Fig\Http\Message\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use function array_merge;
@@ -17,13 +15,6 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet
protected const ROUTE_PATH = '';
protected const ROUTE_ALLOWED_METHODS = [];
protected LoggerInterface $logger;
public function __construct(?LoggerInterface $logger = null)
{
$this->logger = $logger ?: new NullLogger();
}
public static function getRouteDef(array $prevMiddleware = [], array $postMiddleware = []): array
{
return [

View File

@@ -4,11 +4,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Throwable;
@@ -21,13 +20,12 @@ class HealthAction extends AbstractRestAction
protected const ROUTE_PATH = '/health';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private EntityManagerInterface $em;
private AppOptions $options;
private Connection $conn;
public function __construct(Connection $conn, AppOptions $options, ?LoggerInterface $logger = null)
public function __construct(EntityManagerInterface $em, AppOptions $options)
{
parent::__construct($logger);
$this->conn = $conn;
$this->em = $em;
$this->options = $options;
}
@@ -39,7 +37,7 @@ class HealthAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
try {
$connected = $this->conn->ping();
$connected = $this->em->getConnection()->ping();
} catch (Throwable $e) {
$connected = false;
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
use Shlinkio\Shlink\Rest\Exception\MercureException;
use Throwable;
use function sprintf;
class MercureInfoAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/mercure-info';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private JwtProviderInterface $jwtProvider;
private array $mercureConfig;
public function __construct(JwtProviderInterface $jwtProvider, array $mercureConfig)
{
$this->jwtProvider = $jwtProvider;
$this->mercureConfig = $mercureConfig;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$hubUrl = $this->mercureConfig['public_hub_url'] ?? null;
if ($hubUrl === null) {
throw MercureException::mercureNotConfigured();
}
$days = $this->mercureConfig['jwt_days_duration'] ?? 1;
$expiresAt = Chronos::now()->addDays($days);
try {
$jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt);
} catch (Throwable $e) {
throw MercureException::mercureNotConfigured($e);
}
return new JsonResponse([
'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl),
'token' => $jwt,
'jwtExpiration' => $expiresAt->toAtomString(),
]);
}
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@@ -19,12 +18,8 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
private UrlShortenerInterface $urlShortener;
private array $domainConfig;
public function __construct(
UrlShortenerInterface $urlShortener,
array $domainConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
{
$this->urlShortener = $urlShortener;
$this->domainConfig = $domainConfig;
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@@ -19,9 +18,8 @@ class DeleteShortUrlAction extends AbstractRestAction
private DeleteShortUrlServiceInterface $deleteShortUrlService;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, ?LoggerInterface $logger = null)
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct($logger);
$this->deleteShortUrlService = $deleteShortUrlService;
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -20,9 +19,8 @@ class EditShortUrlAction extends AbstractRestAction
private ShortUrlServiceInterface $shortUrlService;
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null)
public function __construct(ShortUrlServiceInterface $shortUrlService)
{
parent::__construct($logger);
$this->shortUrlService = $shortUrlService;
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -20,9 +19,8 @@ class EditShortUrlTagsAction extends AbstractRestAction
private ShortUrlServiceInterface $shortUrlService;
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null)
public function __construct(ShortUrlServiceInterface $shortUrlService)
{
parent::__construct($logger);
$this->shortUrlService = $shortUrlService;
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -24,12 +23,8 @@ class ListShortUrlsAction extends AbstractRestAction
private ShortUrlServiceInterface $shortUrlService;
private array $domainConfig;
public function __construct(
ShortUrlServiceInterface $shortUrlService,
array $domainConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
{
$this->shortUrlService = $shortUrlService;
$this->domainConfig = $domainConfig;
}

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
@@ -21,12 +20,8 @@ class ResolveShortUrlAction extends AbstractRestAction
private ShortUrlResolverInterface $urlResolver;
private array $domainConfig;
public function __construct(
ShortUrlResolverInterface $urlResolver,
array $domainConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig)
{
$this->urlResolver = $urlResolver;
$this->domainConfig = $domainConfig;
}

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Uri;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@@ -22,10 +21,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
public function __construct(
UrlShortenerInterface $urlShortener,
ApiKeyServiceInterface $apiKeyService,
array $domainConfig,
?LoggerInterface $logger = null
array $domainConfig
) {
parent::__construct($urlShortener, $domainConfig, $logger);
parent::__construct($urlShortener, $domainConfig);
$this->apiKeyService = $apiKeyService;
}

View File

@@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class CreateTagsAction extends AbstractRestAction
@@ -18,9 +17,8 @@ class CreateTagsAction extends AbstractRestAction
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
public function __construct(TagServiceInterface $tagService)
{
parent::__construct($logger);
$this->tagService = $tagService;
}

View File

@@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class DeleteTagsAction extends AbstractRestAction
@@ -18,9 +17,8 @@ class DeleteTagsAction extends AbstractRestAction
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
public function __construct(TagServiceInterface $tagService)
{
parent::__construct($logger);
$this->tagService = $tagService;
}

Some files were not shown because too many files have changed in this diff Show More