Merge pull request #1933 from shlinkio/develop

Release 3.7.0
This commit is contained in:
Alejandro Celaya 2023-11-25 20:22:38 +01:00 committed by GitHub
commit c80ec54508
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
159 changed files with 2019 additions and 739 deletions

View file

@ -0,0 +1,51 @@
title: 'Help wanted'
body:
- type: input
validations:
required: true
attributes:
label: Shlink version
placeholder: x.y.z
- type: input
validations:
required: true
attributes:
label: PHP version
placeholder: x.y.z
- type: dropdown
validations:
required: true
attributes:
label: How do you serve Shlink
options:
- Self-hosted Apache
- Self-hosted nginx
- Self-hosted openswoole
- Self-hosted RoadRunner
- Openswoole Docker image
- RoadRunner Docker image
- Other (explain in summary)
- type: dropdown
validations:
required: true
attributes:
label: Database engine
options:
- MySQL
- MariaDB
- PostgreSQL
- MicrosoftSQL
- SQLite
- type: input
validations:
required: true
attributes:
label: Database version
placeholder: x.y.z
- type: textarea
validations:
required: true
attributes:
label: Summary
value: '<!-- Describe your issue, question or request here. -->'

View file

@ -1,38 +0,0 @@
---
name: Bug report
about: Something on shlink is broken or not working as documented?
labels: bug
---
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
-->
#### How Shlink is set up
* Shlink Version: x.y.z
* PHP Version: x.y.z
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary
<!-- Provide a summary describing the problem you are experiencing. -->
#### Current behavior
<!-- How is it actually behaving (and it shouldn't)? -->
#### Expected behavior
<!-- How did you expect it to behave? -->
#### How to reproduce
<!-- Provide steps to reproduce the bug. -->

64
.github/ISSUE_TEMPLATE/Bug.yml vendored Normal file
View file

@ -0,0 +1,64 @@
name: Bug report
description: Something on Shlink is broken or not working as documented?
labels: ['bug']
body:
- type: input
validations:
required: true
attributes:
label: Shlink version
placeholder: x.y.z
- type: input
validations:
required: true
attributes:
label: PHP version
placeholder: x.y.z
- type: dropdown
validations:
required: true
attributes:
label: How do you serve Shlink
options:
- Self-hosted Apache
- Self-hosted nginx
- Self-hosted openswoole
- Self-hosted RoadRunner
- Openswoole Docker image
- RoadRunner Docker image
- Other (explain in summary)
- type: dropdown
validations:
required: true
attributes:
label: Database engine
options:
- MySQL
- MariaDB
- PostgreSQL
- MicrosoftSQL
- SQLite
- type: input
validations:
required: true
attributes:
label: Database version
placeholder: x.y.z
- type: textarea
validations:
required: true
attributes:
label: Current behavior
value: '<!-- How is it actually behaving (and it should not)? -->'
- type: textarea
validations:
required: true
attributes:
label: Expected behavior
value: '<!-- How did you expect it to behave? -->'
- type: textarea
validations:
required: true
attributes:
label: How to reproduce
value: '<!-- Provide steps to reproduce the bug. -->'

View file

@ -1,19 +0,0 @@
---
name: Feature request
about: Do you find shlink is missing some important feature that would make it more useful?
labels: feature
---
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
-->
#### Summary
<!-- Describe the new feature you would like to request. -->

View file

@ -0,0 +1,16 @@
name: Feature request
description: Do you find Shlink is missing some important feature that would make it more useful?
labels: ['feature']
body:
- type: textarea
validations:
required: true
attributes:
label: Summary
value: '<!-- Describe the new feature you would like to request. -->'
- type: textarea
validations:
required: true
attributes:
label: Use case
value: '<!-- Explain why do you think this feature would be useful, and what problems would it help to solve. -->'

View file

@ -1,26 +0,0 @@
---
name: Question - Support
about: Do you have a problem setting up or using shlink?
labels: question
---
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personally if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
With that said, please fill in the information requested next. More information might be requested next (like logs or system configs).
-->
#### How Shlink is set up
* Shlink Version: x.y.z
* PHP Version: x.y.z
* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Self-hosted RoadRunner|Openswoole Docker image|RoadRunner Docker image
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary
<!-- Describe the issue you are facing here. -->

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Question - Support
about: Do you need help setting up or using Shlink?
url: https://github.com/shlinkio/shlink/discussions/new?category=help-wanted

View file

@ -43,5 +43,5 @@ runs:
ini-values: pcov.directory=module
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
run: composer install --no-interaction --prefer-dist
run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-reqs' || '' }}
shell: bash

View file

@ -13,11 +13,12 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1', '8.2']
php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
env:
LC_ALL: C
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Install MSSQL ODBC
if: ${{ inputs.platform == 'ms' }}
run: sudo ./data/infra/ci/install-ms-odbc.sh
@ -27,7 +28,7 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.0.0, pdo_sqlsrv-5.10.1
php-extensions: openswoole-22.1.0, pdo_sqlsrv-5.11.1
extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }}
- name: Create test database
if: ${{ inputs.platform == 'ms' }}
@ -36,7 +37,7 @@ jobs:
run: composer test:db:${{ inputs.platform }}
- name: Upload code coverage
uses: actions/upload-artifact@v3
if: ${{ matrix.php-version == '8.1' && inputs.platform == 'sqlite:ci' }}
if: ${{ matrix.php-version == '8.2' && inputs.platform == 'sqlite:ci' }}
with:
name: coverage-db
path: |

View file

@ -10,5 +10,5 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- run: docker build -t shlink-docker-image:temp .

View file

@ -13,13 +13,14 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1', '8.2']
php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.0.0
php-extensions: openswoole-22.1.0
extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- uses: actions/download-artifact@v3
with:

View file

@ -13,9 +13,10 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1', '8.2']
php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Start postgres database server
if: ${{ inputs.test-group == 'api' }}
run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
@ -25,11 +26,11 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.0.0
php-extensions: openswoole-22.1.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }}
- run: composer test:${{ inputs.test-group }}:ci
- uses: actions/upload-artifact@v3
if: ${{ matrix.php-version == '8.1' }}
if: ${{ matrix.php-version == '8.2' }}
with:
name: coverage-${{ inputs.test-group }}
path: |

View file

@ -29,14 +29,14 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
php-version: ['8.2']
command: ['cs', 'stan', 'swagger:validate']
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.0.0
php-extensions: openswoole-22.1.0
extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }}
- run: composer ${{ matrix.command }}
@ -59,17 +59,18 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1', '8.2']
php-version: ['8.2', '8.3']
continue-on-error: ${{ matrix.php-version == '8.3' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
tools: composer
- run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole
- run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-reqs' || '' }}
- run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr
- run: composer test:api:rr
@ -135,10 +136,10 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
php-version: ['8.2']
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use PHP
uses: shivammathur/setup-php@v2
with:

View file

@ -2,16 +2,6 @@ name: Build and publish docker image
on:
push:
paths-ignore:
- 'LICENSE'
- '.*'
- '*.md'
- '*.xml'
- '*.yml*'
- '*.json5'
- '*.neon'
branches:
- develop
tags:
- 'v*'

View file

@ -10,14 +10,14 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1', '8.2']
php-version: ['8.2', '8.3']
swoole: ['yes', 'no']
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.0.0
php-extensions: openswoole-22.1.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
install-deps: 'no'
- if: ${{ matrix.swoole == 'yes' }}
@ -33,7 +33,7 @@ jobs:
needs: ['build']
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
path: build

View file

@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php-version: ['8.1']
php-version: ['8.2']
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Determine version
id: determine_version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
@ -20,13 +20,13 @@ jobs:
- uses: './.github/actions/ci-setup'
with:
php-version: ${{ matrix.php-version }}
php-extensions: openswoole-22.0.0
php-extensions: openswoole-22.1.0
extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }}
- run: composer swagger:inline
- run: mkdir ${{ steps.determine_version.outputs.version }}
- run: mv docs/swagger/swagger-inlined.json ${{ steps.determine_version.outputs.version }}/open-api-spec.json
- name: Publish spec
uses: JamesIves/github-pages-deploy-action@4.4.1
uses: JamesIves/github-pages-deploy-action@v4
with:
token: ${{ secrets.OAS_PUBLISH_TOKEN }}
repository-name: 'shlinkio/shlink-open-api-specs'

1
.gitignore vendored
View file

@ -9,6 +9,7 @@ vendor/
data/database.sqlite
data/shlink-tests.db
data/GeoLite2-City.*
data/infra/matomo
docs/swagger-ui*
docs/mercure.html
docker-compose.override.yml

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).
## [3.7.0] - 2023-11-25
### Added
* [#1798](https://github.com/shlinkio/shlink/issues/1798) Experimental support to send visits to an external Matomo instance.
* [#1780](https://github.com/shlinkio/shlink/issues/1780) Add new `NO_ORPHAN_VISITS` API key role.
Keys with this role will always get `0` when fetching orphan visits.
When trying to delete orphan visits the result will also be `0` and no visits will actually get deleted.
* [#1879](https://github.com/shlinkio/shlink/issues/1879) Cache namespace can now be customized via config option or `CACHE_NAMESPACE` env var.
This is important if you are running multiple Shlink instance on the same server, or they share the same Redis instance (even more so if they are on different versions).
* [#1905](https://github.com/shlinkio/shlink/issues/1905) Add support for PHP 8.3.
* [#1927](https://github.com/shlinkio/shlink/issues/1927) Allow redis credentials be URL-decoded before passing them to connection.
* [#1834](https://github.com/shlinkio/shlink/issues/1834) Add support for redis encrypted connections using SSL/TLS.
Encryption should work out of the box if servers schema is set tp `tls` or `rediss`, including support for self-signed certificates.
This has been tested with AWS ElasticCache using in-transit encryption, and with Digital Ocean Redis database.
* [#1906](https://github.com/shlinkio/shlink/issues/1906) Add support for RabbitMQ encrypted connections using SSL/TLS.
In order to enable SLL, you need to pass `RABBITMQ_USE_SSL=true` or the corresponding config option.
Connections using self-signed certificates should work out of the box.
This has been tested with AWS RabbitMQ using in-transit encryption, and with CloudAMQP.
### Changed
* [#1799](https://github.com/shlinkio/shlink/issues/1799) RoadRunner/openswoole jobs are not run anymore for tasks that are actually disabled.
For example, if you did not enable RabbitMQ real-time updates, instead of triggering a job that ends immediately, the job will not even be enqueued.
* [#1835](https://github.com/shlinkio/shlink/issues/1835) Docker image is now built only when a release is tagged, and new tags are included, for minor and major versions.
* [#1055](https://github.com/shlinkio/shlink/issues/1055) Update OAS definition to v3.1.
* [#1885](https://github.com/shlinkio/shlink/issues/1885) Update to chronos 3.0.
* [#1896](https://github.com/shlinkio/shlink/issues/1896) Requests to health endpoint are no longer logged.
* [#1877](https://github.com/shlinkio/shlink/issues/1877) Print a warning when manually running `visit:download-db` command and a GeoLite2 license was not provided.
### Deprecated
* [#1783](https://github.com/shlinkio/shlink/issues/1783) Deprecated support for openswoole. RoadRunner is the best replacement, with the same capabilities, but much easier and convenient to install and manage.
### Removed
* [#1790](https://github.com/shlinkio/shlink/issues/1790) Drop support for PHP 8.1.
### Fixed
* [#1819](https://github.com/shlinkio/shlink/issues/1819) Fix incorrect timeout when running DB commands during Shlink start-up.
* [#1901](https://github.com/shlinkio/shlink/issues/1901) Do not allow short URLs with custom slugs containing URL-reserved characters, as they will not work at all afterward.
* [#1900](https://github.com/shlinkio/shlink/issues/1900) Fix short URL visits deletion when multi-segment slugs are enabled.
## [3.6.4] - 2023-09-23
### Added
* *Nothing*
@ -112,7 +164,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* Versions with `-openswoole` suffix (like `3.6.0-openswoole`) will always use openswoole as the runtime, even if default one changes in the future.
### Deprecated
* *Nothing*
* Deprecated `ENABLE_PERIODIC_VISIT_LOCATE` env var. Use an external mechanism to automate visit locations.
### Removed
* *Nothing*

View file

@ -6,9 +6,9 @@ You will also see how to ensure the code fulfills the expected code checks, and
## System dependencies
The project provides all its dependencies as docker containers through a docker-compose configuration.
The project provides all its dependencies as docker containers through a `docker compose` configuration.
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker compose](https://docs.docker.com/compose/install/).
## Setting up the project
@ -21,7 +21,7 @@ Then you will have to follow these steps:
For example the `common.local.php.dist` file should be copied as `common.local.php`.
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
* Start-up the project by running `docker-compose up`.
* Start-up the project by running `docker compose up`.
The first time this command is run, it will create several containers that are used during development, so it may take some time.
@ -31,7 +31,7 @@ Then you will have to follow these steps:
* Run `./indocker bin/cli db:migrate` to get database migrations up to date.
* Run `./indocker bin/cli api-key:generate` to get your first API key generated.
Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through openswoole.
Once you finish this, you will have the project exposed in ports `8800` through RoadRunner, `8080` through openswoole and `8000` through nginx+php-fpm.
> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container.
@ -73,12 +73,12 @@ shlink
The purposes of every folder are:
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run shlink from the command line.
* `bin`: It contains the CLI tools. The `cli` one is the main entry point to run Shlink from the command line.
* `config`: Contains application-wide configurations, which are later merged with the ones provided by every module.
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
* `module`: Contains a sub-folder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with openswoole.
* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with RoadRunner or openswoole.
## Project tests
@ -94,7 +94,7 @@ In order to ensure stability and no regressions are introduced while developing
The project provides some tooling to run them against any of the supported database engines.
* **API tests**: These are E2E tests that spin up an instance of the app with openswoole, and test it from the outside by interacting with the REST API.
* **API tests**: These are E2E tests that spin up an instance of the app with RoadRunner or openswoole, and test it from the outside by interacting with the REST API.
These are the best tests to catch regressions, and to verify everything behaves as expected.
@ -125,6 +125,12 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
* Run `./indocker composer ci` to run all previous commands together, parallelizing non-conflicting tasks as much as possible.
## Testing endpoints
The project provides a Swagger UI container for dev envs, which can be accessed in http://localhost:8005.
It will automatically load the contents of `docs/swagger`, so you can make any updates and they will get reflected.
## Pull request process
**Important!**: Before starting to work on a pull request, make sure you always [open an issue](https://github.com/shlinkio/shlink/issues/new/choose) first.

View file

@ -7,8 +7,8 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME}
ARG SHLINK_USER_ID='root'
ENV SHLINK_USER_ID ${SHLINK_USER_ID}
ENV OPENSWOOLE_VERSION 22.0.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV OPENSWOOLE_VERSION 22.1.0
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1
ENV LC_ALL 'C'
@ -29,6 +29,7 @@ RUN \
# Install openswoole and sqlsrv driver for x86_64 builds
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole ; \
fi; \
@ -49,6 +50,7 @@ RUN apk add --no-cache git && \
# FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev
php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \
# Openswoole is deprecated. Remove in v4.0.0
php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \
php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \

View file

@ -32,11 +32,11 @@ You can learn how to use the official docker image by reading [the docs](https:/
The idea is that you can just generate a container using the image and provide the custom config via env vars.
## Self hosted
## Self-hosted
First, make sure the host where you are going to run shlink fulfills these requirements:
* PHP 8.1 or 8.2
* PHP 8.2 or 8.3
* The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath.
* apcu extension is recommended if you don't plan to use openswoole.
* xml extension is required if you want to generate QR codes in svg format.

View file

@ -2,7 +2,7 @@
export APP_ENV=test
export TEST_ENV=api
export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}"
export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" # Openswoole is deprecated. Remove in v4.0.0
export DB_DRIVER="${DB_DRIVER:-"postgres"}"
export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}"

View file

@ -10,6 +10,7 @@ fi
version=$1
noSwoole=$2
phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')
# Openswoole is deprecated. Remove in v4.0.0
[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole"
distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist"
builtContent="./build/${distId}"
@ -30,7 +31,8 @@ cd "${builtContent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
composerFlags="--optimize-autoloader --no-progress --no-interaction"
# Deprecated. Do not ignore PHP platform req for Shlink v4.0.0
composerFlags="--optimize-autoloader --no-progress --no-interaction --ignore-platform-req=php+"
${composerBin} self-update
${composerBin} install --no-dev --prefer-dist $composerFlags
@ -38,6 +40,7 @@ if [[ $noSwoole ]]; then
# If generating a dist not for openswoole, uninstall mezzio-swoole
${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
else
# Deprecated. Remove in Shlink v4.0.0
# If generating a dist for openswoole, uninstall RoadRunner
${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags
fi
@ -46,7 +49,7 @@ fi
echo 'Deleting dev files...'
rm composer.*
# Update shlink version in config
# Update Shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file

View file

@ -12,72 +12,73 @@
}
],
"require": {
"php": "^8.1",
"php": "^8.2",
"ext-curl": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.1",
"cakephp/chronos": "~2.3.3",
"doctrine/migrations": "^3.5",
"doctrine/orm": "^2.14",
"endroid/qr-code": "^4.7",
"cakephp/chronos": "^3.0.2",
"doctrine/migrations": "^3.6",
"doctrine/orm": "^2.16",
"endroid/qr-code": "^4.8",
"friendsofphp/proxy-manager-lts": "^1.0",
"geoip2/geoip2": "^2.13",
"guzzlehttp/guzzle": "^7.5",
"happyr/doctrine-specification": "^2.0",
"jaybizzle/crawler-detect": "^1.2.112",
"jaybizzle/crawler-detect": "^1.2.116",
"laminas/laminas-config": "^3.8",
"laminas/laminas-config-aggregator": "^1.13",
"laminas/laminas-diactoros": "^2.24",
"laminas/laminas-inputfilter": "^2.24",
"laminas/laminas-servicemanager": "^3.20",
"laminas/laminas-stdlib": "^3.16",
"laminas/laminas-diactoros": "^2.25",
"laminas/laminas-inputfilter": "^2.27",
"laminas/laminas-servicemanager": "^3.21",
"laminas/laminas-stdlib": "^3.17",
"league/uri": "^6.8",
"lstrojny/functional-php": "^1.17",
"mezzio/mezzio": "^3.15",
"mezzio/mezzio-fastroute": "^3.8",
"mezzio/mezzio-problem-details": "^1.11",
"mezzio/mezzio-swoole": "^4.6",
"matomo/matomo-php-tracker": "^3.2",
"mezzio/mezzio": "^3.17",
"mezzio/mezzio-fastroute": "^3.10",
"mezzio/mezzio-problem-details": "^1.13",
"mezzio/mezzio-swoole": "^4.7",
"mlocati/ip-lib": "^1.18",
"mobiledetect/mobiledetectlib": "^3.74",
"ocramius/proxy-manager": "^2.14",
"pagerfanta/core": "^3.7",
"mobiledetect/mobiledetectlib": "^4.8",
"pagerfanta/core": "^3.8",
"php-middleware/request-id": "^4.1",
"pugx/shortid-php": "^1.1",
"ramsey/uuid": "^4.7",
"shlinkio/shlink-common": "^5.6",
"shlinkio/shlink-config": "^2.4",
"shlinkio/shlink-event-dispatcher": "^3.0",
"shlinkio/shlink-importer": "^5.1",
"shlinkio/shlink-installer": "^8.5",
"shlinkio/shlink-ip-geolocation": "^3.2",
"shlinkio/shlink-json": "^1.0",
"spiral/roadrunner": "^2023.1",
"shlinkio/shlink-common": "^5.7",
"shlinkio/shlink-config": "^2.5",
"shlinkio/shlink-event-dispatcher": "^3.1",
"shlinkio/shlink-importer": "^5.2",
"shlinkio/shlink-installer": "^8.6",
"shlinkio/shlink-ip-geolocation": "^3.3",
"shlinkio/shlink-json": "^1.1",
"spiral/roadrunner": "^2023.2",
"spiral/roadrunner-cli": "^2.5",
"spiral/roadrunner-http": "^3.0",
"spiral/roadrunner-http": "^3.1",
"spiral/roadrunner-jobs": "^4.0",
"symfony/console": "^6.2",
"symfony/filesystem": "^6.2",
"symfony/lock": "^6.2",
"symfony/process": "^6.2",
"symfony/string": "^6.2"
"symfony/console": "^6.3",
"symfony/filesystem": "^6.3",
"symfony/lock": "^6.3",
"symfony/process": "^6.3",
"symfony/string": "^6.3"
},
"require-dev": {
"cebe/php-openapi": "^1.7",
"devizzent/cebe-php-openapi": "^1.0.1",
"devster/ubench": "^2.1",
"infection/infection": "^0.27",
"openswoole/ide-helper": "~22.0.0",
"phpstan/phpstan": "^1.9",
"phpstan/phpstan": "^1.10",
"phpstan/phpstan-doctrine": "^1.3",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-symfony": "^1.2",
"phpunit/php-code-coverage": "^10.0",
"phpunit/phpunit": "~10.1.0",
"phpstan/phpstan-symfony": "^1.3",
"phpunit/php-code-coverage": "^10.1",
"phpunit/phpunit": "^10.4",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "~3.6.0",
"symfony/var-dumper": "^6.2",
"veewee/composer-run-parallel": "^1.2"
"shlinkio/shlink-test-utils": "^3.8",
"symfony/var-dumper": "^6.3",
"veewee/composer-run-parallel": "^1.3"
},
"autoload": {
"psr-4": {
@ -137,7 +138,7 @@
"infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests",
"infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80",
"infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json5",
"infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=95 --configuration=infection-api.json5",
"infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5",
"infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli",
"infect:test": [
@ -148,6 +149,10 @@
"@test:unit:ci",
"@infect:ci:unit"
],
"infect:test:db": [
"@test:db:sqlite:ci",
"@infect:ci:db"
],
"infect:test:api": [
"@test:api:ci",
"@infect:ci:api"

View file

@ -11,12 +11,13 @@ return (static function (): array {
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
'decode_credentials' => (bool) EnvVars::REDIS_DECODE_CREDENTIALS->loadFromEnv(false),
],
];
return [
'cache' => [
'namespace' => 'Shlink',
'namespace' => EnvVars::CACHE_NAMESPACE->loadFromEnv('Shlink'),
...$cacheRedisBlock,
],
'redis' => $redis,

View file

@ -11,6 +11,7 @@ return [
'installer' => [
'enabled_options' => [
Option\Server\RuntimeConfigOption::class,
Option\Database\DatabaseDriverConfigOption::class,
Option\Database\DatabaseNameConfigOption::class,
Option\Database\DatabaseHostConfigOption::class,
@ -28,9 +29,11 @@ return [
Option\Visit\VisitsThresholdConfigOption::class,
Option\BasePathConfigOption::class,
Option\TimezoneConfigOption::class,
Option\Cache\CacheNamespaceConfigOption::class,
Option\Worker\TaskWorkerNumConfigOption::class,
Option\Worker\WebWorkerNumConfigOption::class,
Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisDecodeCredentialsConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
@ -61,10 +64,15 @@ return [
Option\QrCode\DefaultRoundBlockSizeConfigOption::class,
Option\RabbitMq\RabbitMqEnabledConfigOption::class,
Option\RabbitMq\RabbitMqHostConfigOption::class,
Option\RabbitMq\RabbitMqUseSslConfigOption::class,
Option\RabbitMq\RabbitMqPortConfigOption::class,
Option\RabbitMq\RabbitMqUserConfigOption::class,
Option\RabbitMq\RabbitMqPasswordConfigOption::class,
Option\RabbitMq\RabbitMqVhostConfigOption::class,
Option\Matomo\MatomoEnabledConfigOption::class,
Option\Matomo\MatomoBaseUrlConfigOption::class,
Option\Matomo\MatomoSiteIdConfigOption::class,
Option\Matomo\MatomoApiTokenConfigOption::class,
],
'installation_commands' => [

View file

@ -52,6 +52,7 @@ return (static function (): array {
],
],
// Deprecated. Remove in Shlink 4.0.0
'mezzio-swoole' => [
'swoole-http-server' => [
'logger' => [

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'matomo' => [
'enabled' => (bool) EnvVars::MATOMO_ENABLED->loadFromEnv(false),
'base_url' => EnvVars::MATOMO_BASE_URL->loadFromEnv(),
'site_id' => EnvVars::MATOMO_SITE_ID->loadFromEnv(),
'api_token' => EnvVars::MATOMO_API_TOKEN->loadFromEnv(),
],
];

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* Dev matomo instance needs to be manually configured once before enabling the configuration below.
*
* 1. Go to http://localhost:8003 and follow the installation instructions.
* 2. Open data/infra/matomo/config/config.ini.php and replace `trusted_hosts[] = "localhost"` with
* `trusted_hosts[] = "localhost:8003"` (see https://github.com/matomo-org/matomo/issues/9549)
* 3. Go to http://localhost:8003/index.php?module=SitesManager&action=index and paste the ID for the site you just
* created into the `site_id` field below.
* 4. Go to http://localhost:8003/index.php?module=UsersManager&action=userSecurity, scroll down, click
* "Create new token" and once generated, paste the token into the `api_token` field below.
*/
return [
'matomo' => [
// 'enabled' => true,
// 'base_url' => 'http://shlink_matomo',
// 'site_id' => '...',
// 'api_token' => '...',
],
];

View file

@ -9,6 +9,7 @@ return [
'rabbitmq' => [
'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false),
'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(),
'use_ssl' => (bool) EnvVars::RABBITMQ_USE_SSL->loadFromEnv(false),
'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'),
'user' => EnvVars::RABBITMQ_USER->loadFromEnv(),
'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(),

View file

@ -32,8 +32,11 @@ return (static function (): array {
...ConfigProvider::applyRoutesPrefix([
Action\HealthAction::getRouteDef(),
// Visits
// Visits.
// These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs
// are enabled, routes with a less-specific path might match first
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
Action\Visit\DomainVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
@ -54,7 +57,6 @@ return (static function (): array {
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),

View file

@ -2,11 +2,26 @@
declare(strict_types=1);
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Console\ConsoleRunner;
use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
use Doctrine\Migrations\Configuration\Migration\ConfigurationArray;
use Doctrine\Migrations\DependencyFactory;
// This file is currently used by doctrine migrations only
return (static function () {
/** @var EntityManager $em */
$migrationsConfig = [
'migrations_paths' => [
'ShlinkMigrations' => 'data/migrations',
],
'table_storage' => [
'table_name' => 'migrations',
],
'custom_template' => 'data/migrations_template.txt',
];
$em = include __DIR__ . '/entity-manager.php';
return ConsoleRunner::createHelperSet($em);
return DependencyFactory::fromEntityManager(
new ConfigurationArray($migrationsConfig),
new ExistingEntityManager($em),
);
})();

View file

@ -22,33 +22,39 @@ use const PHP_SAPI;
$isTestEnv = env('APP_ENV') === 'test';
$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner();
return (new ConfigAggregator\ConfigAggregator([
! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
$enableSwoole && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
Config\ConfigProvider::class,
Importer\ConfigProvider::class,
IpGeolocation\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'),
// Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests
new ConfigAggregator\PhpFileProvider($isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php'),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
], 'data/cache/app_config.php', [
Core\Config\PostProcessor\BasePathPrefixer::class,
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
]))->getMergedConfig();
return (new ConfigAggregator\ConfigAggregator(
providers: [
! $isTestEnv
? new EnvVarLoaderProvider('config/params/generated_config.php', enumValues(Core\Config\EnvVars::class))
: new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
$enableSwoole && class_exists(Swoole\ConfigProvider::class)
? Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ProblemDetails\ConfigProvider::class,
Diactoros\ConfigProvider::class,
Common\ConfigProvider::class,
Config\ConfigProvider::class,
Importer\ConfigProvider::class,
IpGeolocation\ConfigProvider::class,
EventDispatcher\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{,*.}global.php'),
// Local config should not be loaded during tests, whereas test config should be loaded ONLY during tests
new ConfigAggregator\PhpFileProvider(
$isTestEnv ? 'config/test/*.global.php' : 'config/autoload/{,*.}local.php',
),
// Routes have to be loaded last
new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'),
],
cachedConfigFile: 'data/cache/app_config.php',
postProcessors: [
Core\Config\PostProcessor\BasePathPrefixer::class,
Core\Config\PostProcessor\MultiSegmentSlugProcessor::class,
Core\Config\PostProcessor\ShortUrlMethodsProcessor::class,
],
))->getMergedConfig();

View file

@ -13,6 +13,7 @@ chdir(dirname(__DIR__));
require 'vendor/autoload.php';
// Workaround to make this compatible with both openswoole 22 and earlier versions.
// Openswoole support is deprecated. Remove in v4.0.0
if (! function_exists('swoole_set_process_name')) {
// phpcs:disable
function swoole_set_process_name(string $name): void

View file

@ -11,7 +11,7 @@ server {
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
include fastcgi.conf;
}

View file

@ -2,7 +2,7 @@ FROM php:8.2-fpm-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.10.1
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1

View file

@ -2,7 +2,7 @@ FROM php:8.2-alpine3.17
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV PDO_SQLSRV_VERSION 5.10.1
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1

View file

@ -3,8 +3,8 @@ MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
ENV OPENSWOOLE_VERSION 22.0.0
ENV PDO_SQLSRV_VERSION 5.10.1
ENV OPENSWOOLE_VERSION 22.1.0
ENV PDO_SQLSRV_VERSION 5.11.1
ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486'
ENV MS_ODBC_SQL_VERSION 18_18.1.1.1

View file

@ -3,7 +3,7 @@ version: '3'
services:
shlink_nginx:
container_name: shlink_nginx
image: nginx:1.19.6-alpine
image: nginx:1.25-alpine
ports:
- "8000:80"
volumes:
@ -33,6 +33,7 @@ services:
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
@ -40,7 +41,7 @@ services:
shlink_swoole_proxy:
container_name: shlink_swoole_proxy
image: nginx:1.19.6-alpine
image: nginx:1.25-alpine
ports:
- "8002:80"
volumes:
@ -70,6 +71,7 @@ services:
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
@ -95,6 +97,7 @@ services:
- shlink_mercure
- shlink_mercure_proxy
- shlink_rabbitmq
- shlink_matomo
environment:
LC_ALL: C
extra_hosts:
@ -164,7 +167,7 @@ services:
shlink_mercure_proxy:
container_name: shlink_mercure_proxy
image: nginx:1.19.6-alpine
image: nginx:1.25-alpine
ports:
- "8001:80"
volumes:
@ -175,7 +178,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.14
image: dunglas/mercure:v0.15
ports:
- "3080:80"
environment:
@ -186,10 +189,36 @@ services:
shlink_rabbitmq:
container_name: shlink_rabbitmq
image: rabbitmq:3.9-management-alpine
image: rabbitmq:3.11-management-alpine
ports:
- "15672:15672"
- "5672:5672"
environment:
RABBITMQ_DEFAULT_USER: "rabbit"
RABBITMQ_DEFAULT_PASS: "rabbit"
shlink_swagger_ui:
container_name: shlink_swagger_ui
image: swaggerapi/swagger-ui:v5.9.1
ports:
- "8005:8080"
volumes:
- ./docs/swagger:/app
shlink_matomo:
container_name: shlink_matomo
image: matomo:4.15-apache
ports:
- "8003:80"
volumes:
# Matomo does not persist port in trusted hosts. This volume is needed to edit config afterward
# https://github.com/matomo-org/matomo/issues/9549
- ./data/infra/matomo:/var/www/html
links:
- shlink_db_mysql
environment:
MATOMO_DATABASE_HOST: "shlink_db_mysql"
MATOMO_DATABASE_ADAPTER: "mysql"
MATOMO_DATABASE_DBNAME: "matomo"
MATOMO_DATABASE_USERNAME: "root"
MATOMO_DATABASE_PASSWORD: "root"

View file

@ -5,7 +5,7 @@
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
It exposes a shlink instance served with [openswoole](https://openswoole.com/), which can be linked to external databases to persist data.
It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev) or [openswoole](https://openswoole.com/), which can be linked to external databases to persist data.
## Usage

View file

@ -3,10 +3,10 @@ set -e
cd /etc/shlink
flags="--clear-db-cache"
flags="--no-interaction --clear-db-cache"
# Skip downloading GeoLite2 db file if the license key env var was not defined or skipping was explicitly set
if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" == "true" ]; then
if [ -z "${GEOLITE_LICENSE_KEY}" ] || [ "${SKIP_INITIAL_GEOLITE_DOWNLOAD}" = "true" ]; then
flags="${flags} --skip-download-geolite"
fi
@ -25,10 +25,11 @@ if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "ro
/usr/sbin/crond &
fi
if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then
if [ "$SHLINK_RUNTIME" = 'openswoole' ]; then
# Openswoole is deprecated. Remove in Shlink 4.0.0
# When restarting the container, openswoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done
elif [ "$SHLINK_RUNTIME" == 'rr' ]; then
elif [ "$SHLINK_RUNTIME" = 'rr' ]; then
./bin/rr serve -c config/roadrunner/.rr.yml
fi

View file

@ -0,0 +1,52 @@
# Build `latest` docker image only for actual releases
* Status: Accepted
* Date: 2023-07-09
## Context and problem statement
Historically, this project has re-tagged the `latest´ docker image every time a PR was merged into default branch.
The reason was to be able to:
* Periodically test the docker building and publishing process.
* Provide "partial" images for quick testing of new "un-released" features.
However, this was considered non-stable, and not recommended to use in production. Instead, a convenient `stable` tag was provided, which was re-tagged for every new non-beta/non-alpha release.
The approach described above for `latest` has some problems, though:
* Many people ignore the recommendation of not using it in production. There have even been reports of bugs on things which were, technically speaking, not yet released.
* Since it is not always built for an actual new project version, the project itself cannot inform about anything other than `latest`, which can quickly become a lie if you don't update your local version.
## Considered options
* Try to provide a pseudo-version when `latest` is built. Something like `<prev_version>-<commit_hash>.
* Change how `latest` is published, and start tagging it only for actual new version releases.
* Same as the above, but exclude alpha/beta versions, deprecating `stable` tag.
## Decision outcome
Since testing un-released features has never been needed, it is probably a not-very useful thing to have.
Periodically testing the build and publish process can also be moved somewhere else, like a testing "hidden" account.
Also, having `stable` with non-alpha/non-beta releases seems sensible, so the decision is to "Change how `latest` is published, and start tagging it only for actual new version releases".
## Pros and Cons of the Options
### Try to provide a pseudo-version when `latest` is built.
* Good: because we keep publishing process intact, from a user point of view.
* Bad: because it requires adding some non-trivial logic to the image building, which needs to find out what was the latest stable release.
### Make `latest` hold latest published version, including unstable releases.
* Good: because it provides a way for users to test bleeding-edge features, with less risk than relying on the very last content from default branch.
* Good: because it allows for `stable` to be used together with `latest`.
* Bad: because partial features cannot be tested without publishing an alpha or beta version.
### Make `latest` hold latest published version, excluding unstable releases.
* Bad: because there's no longer a way to test bleeding-edge features, other than installing that specific version.
* Bad: because it drives `stable` useless, which means it needs to be deprecated, documented, and eventually removed.

View file

@ -2,6 +2,7 @@
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
* [2023-07-09Build `latest` docker image only for actual releases](2023-07-09-build-latest-docker-image-only-for-actual-releases.md)
* [2023-01-06 Support any HTTP method in short URLs](2023-01-06-support-any-http-method-in-short-urls.md)
* [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md)
* [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md)

View file

@ -3,18 +3,15 @@
"properties": {
"android": {
"description": "The long URL to redirect to when the short URL is visited from a device running Android",
"type": "string",
"nullable": false
"type": ["string"]
},
"ios": {
"description": "The long URL to redirect to when the short URL is visited from a device running iOS",
"type": "string",
"nullable": false
"type": ["string"]
},
"desktop": {
"description": "The long URL to redirect to when the short URL is visited from a desktop browser",
"type": "string",
"nullable": false
"type": ["string"]
}
}
}

View file

@ -5,13 +5,13 @@
}],
"properties": {
"android": {
"nullable": true
"type": ["null"]
},
"ios": {
"nullable": true
"type": ["null"]
},
"desktop": {
"nullable": true
"type": ["null"]
}
}
}

View file

@ -2,18 +2,15 @@
"type": "object",
"properties": {
"baseUrlRedirect": {
"type": "string",
"nullable": true,
"type": ["string", "null"],
"description": "URL to redirect to when a user hits the domain's base URL"
},
"regular404Redirect": {
"type": "string",
"nullable": true,
"type": ["string", "null"],
"description": "URL to redirect to when a user hits a not found URL other than an invalid short URL"
},
"invalidShortUrlRedirect": {
"type": "string",
"nullable": true,
"type": ["string", "null"],
"description": "URL to redirect to when a user hits an invalid short URL"
}
}

View file

@ -6,8 +6,7 @@
}],
"properties": {
"visitedUrl": {
"type": "string",
"nullable": true,
"type": ["string", "null"],
"description": "The originally visited URL that triggered the tracking of this visit"
},
"type": {

View file

@ -55,13 +55,11 @@
"$ref": "./ShortUrlMeta.json"
},
"domain": {
"type": "string",
"nullable": true,
"type": ["string", "null"],
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
},
"title": {
"type": "string",
"nullable": true,
"type": ["string", "null"],
"description": "A descriptive title of the short URL."
},
"crawlable": {

View file

@ -10,18 +10,15 @@
},
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string",
"nullable": true
"type": ["string", "null"]
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string",
"nullable": true
"type": ["string", "null"]
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number",
"nullable": true
"type": ["number", "null"]
},
"validateUrl": {
"deprecated": true,
@ -36,9 +33,8 @@
"description": "The list of tags to set to the short URL."
},
"title": {
"type": "string",
"description": "A descriptive title of the short URL.",
"nullable": true
"type": ["string", "null"],
"description": "A descriptive title of the short URL."
},
"crawlable": {
"type": "boolean",

View file

@ -4,18 +4,15 @@
"properties": {
"validSince": {
"description": "The date (in ISO-8601 format) from which this short code will be valid",
"type": "string",
"nullable": true
"type": ["string", "null"]
},
"validUntil": {
"description": "The date (in ISO-8601 format) until which this short code will be valid",
"type": "string",
"nullable": true
"type": ["string", "null"]
},
"maxVisits": {
"description": "The maximum number of allowed visits for this short code",
"type": "number",
"nullable": true
"type": ["number", "null"]
}
}
}

View file

@ -1,5 +1,5 @@
{
"openapi": "3.0.3",
"openapi": "3.1.0",
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",

View file

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

View file

@ -24,6 +24,7 @@ class RoleResolver implements RoleResolverInterface
{
$domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName());
$author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName());
$noOrphanVisits = $input->getOption(Role::NO_ORPHAN_VISITS->paramName());
if ($author) {
yield RoleDefinition::forAuthoredShortUrls();
@ -31,6 +32,9 @@ class RoleResolver implements RoleResolverInterface
if (is_string($domainAuthority)) {
yield $this->resolveRoleForAuthority($domainAuthority);
}
if ($noOrphanVisits) {
yield RoleDefinition::forNoOrphanVisits();
}
}
private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition

View file

@ -36,6 +36,8 @@ class GenerateKeyCommand extends Command
{
$authorOnly = Role::AUTHORED_SHORT_URLS->paramName();
$domainOnly = Role::DOMAIN_SPECIFIC->paramName();
$noOrphanVisits = Role::NO_ORPHAN_VISITS->paramName();
$help = <<<HELP
The <info>%command.name%</info> generates a new valid API key.
@ -53,7 +55,8 @@ class GenerateKeyCommand extends Command
* Can interact with short URLs created with this API key: <info>%command.full_name% --{$authorOnly}</info>
* Can interact with short URLs for one domain: <info>%command.full_name% --{$domainOnly}=example.com</info>
* Both: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com</info>
* Cannot see orphan visits: <info>%command.full_name% --{$noOrphanVisits}</info>
* All: <info>%command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits}</info>
HELP;
$this
@ -86,6 +89,12 @@ class GenerateKeyCommand extends Command
Role::DOMAIN_SPECIFIC->value,
),
)
->addOption(
$noOrphanVisits,
'o',
InputOption::VALUE_NONE,
sprintf('Adds the "%s" role to the new API key.', Role::NO_ORPHAN_VISITS->value),
)
->setHelp($help);
}

View file

@ -27,7 +27,7 @@ class ListKeysCommand extends Command
public const NAME = 'api-key:list';
public function __construct(private ApiKeyServiceInterface $apiKeyService)
public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
{
parent::__construct();
}
@ -60,10 +60,7 @@ class ListKeysCommand extends Command
}
$rowData[] = $expiration?->toAtomString() ?? '-';
$rowData[] = ApiKey::isAdmin($apiKey) ? 'Admin' : implode("\n", $apiKey->mapRoles(
fn (Role $role, array $meta) =>
empty($meta)
? $role->toFriendlyName()
: sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)),
fn (Role $role, array $meta) => $role->toFriendlyName($meta),
));
return $rowData;

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
@ -41,7 +42,7 @@ class DownloadGeoLiteDbCommand extends Command
$io = new SymfonyStyle($input, $output);
try {
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
$result = $this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) use ($io): void {
$io->text(sprintf('<fg=blue>%s GeoLite2 db file...</>', $olderDbExists ? 'Updating' : 'Downloading'));
$this->progressBar = new ProgressBar($io);
}, function (int $total, int $downloaded): void {
@ -49,6 +50,11 @@ class DownloadGeoLiteDbCommand extends Command
$this->progressBar?->setProgress($downloaded);
});
if ($result === GeolocationResult::LICENSE_MISSING) {
$io->warning('It was not possible to download GeoLite2 db, because a license was not provided.');
return ExitCode::EXIT_WARNING;
}
if ($this->progressBar === null) {
$io->info('GeoLite2 db file is up to date.');
} else {
@ -58,21 +64,26 @@ class DownloadGeoLiteDbCommand extends Command
return ExitCode::EXIT_SUCCESS;
} catch (GeolocationDbUpdateFailedException $e) {
$olderDbExists = $e->olderDbExists();
if ($olderDbExists) {
$io->warning(
'GeoLite2 db file update failed. Visits will continue to be located with the old version.',
);
} else {
$io->error('GeoLite2 db file download failed. It will not be possible to locate visits.');
}
if ($io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE;
return $this->processGeoLiteUpdateError($e, $io);
}
}
private function processGeoLiteUpdateError(GeolocationDbUpdateFailedException $e, SymfonyStyle $io): int
{
$olderDbExists = $e->olderDbExists();
if ($olderDbExists) {
$io->warning(
'GeoLite2 db file update failed. Visits will continue to be located with the old version.',
);
} else {
$io->error('GeoLite2 db file download failed. It will not be possible to locate visits.');
}
if ($io->isVerbose()) {
$this->getApplication()?->renderThrowable($e, $io);
}
return $olderDbExists ? ExitCode::EXIT_WARNING : ExitCode::EXIT_FAILURE;
}
}

View file

@ -70,7 +70,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
$buildTimestamp = $this->resolveBuildTimestamp($meta);
$buildDate = Chronos::createFromTimestamp($buildTimestamp);
return Chronos::now()->gt($buildDate->addDays(35));
return Chronos::now()->greaterThan($buildDate->addDays(35));
}
private function resolveBuildTimestamp(Metadata $meta): int
@ -108,8 +108,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
$this->dbUpdater->downloadFreshCopy($this->wrapHandleProgressCallback($handleProgress, $olderDbExists));
return $olderDbExists ? GeolocationResult::DB_UPDATED : GeolocationResult::DB_CREATED;
} catch (MissingLicenseException) {
// If there's no license key, just ignore the error
return GeolocationResult::CHECK_SKIPPED;
return GeolocationResult::LICENSE_MISSING;
} catch (DbUpdateException | WrongIpException $e) {
throw $olderDbExists
? GeolocationDbUpdateFailedException::withOlderDb($e)

View file

@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\CLI\GeoLite;
enum GeolocationResult
{
case CHECK_SKIPPED;
case LICENSE_MISSING;
case DB_CREATED;
case DB_UPDATED;
case DB_IS_UP_TO_DATE;

View file

@ -19,11 +19,7 @@ use function unlink;
class ImportShortUrlsTest extends CliTestCase
{
/**
* @var false|string|null
* @todo Use native type once PHP 8.1 support is dropped
*/
private mixed $tempCsvFile = null;
private false|string|null $tempCsvFile = null;
protected function setUp(): void
{

View file

@ -24,36 +24,40 @@ class ListApiKeysTest extends CliTestCase
public static function provideFlags(): iterable
{
$expiredApiKeyDate = Chronos::now()->subDay()->startOfDay()->toAtomString();
$expiredApiKeyDate = Chronos::now()->subDays(1)->startOfDay()->toAtomString();
$enabledOnlyOutput = <<<OUT
+-----------------+------+---------------------------+--------------------------+
| Key | Name | Expiration date | Roles |
+-----------------+------+---------------------------+--------------------------+
| valid_api_key | - | - | Admin |
+-----------------+------+---------------------------+--------------------------+
| expired_api_key | - | {$expiredApiKeyDate} | Admin |
+-----------------+------+---------------------------+--------------------------+
| author_api_key | - | - | Author only |
+-----------------+------+---------------------------+--------------------------+
| domain_api_key | - | - | Domain only: example.com |
+-----------------+------+---------------------------+--------------------------+
+--------------------+------+---------------------------+--------------------------+
| Key | Name | Expiration date | Roles |
+--------------------+------+---------------------------+--------------------------+
| valid_api_key | - | - | Admin |
+--------------------+------+---------------------------+--------------------------+
| expired_api_key | - | {$expiredApiKeyDate} | Admin |
+--------------------+------+---------------------------+--------------------------+
| author_api_key | - | - | Author only |
+--------------------+------+---------------------------+--------------------------+
| domain_api_key | - | - | Domain only: example.com |
+--------------------+------+---------------------------+--------------------------+
| no_orphans_api_key | - | - | No orphan visits |
+--------------------+------+---------------------------+--------------------------+
OUT;
yield 'no flags' => [[], <<<OUT
+------------------+------+------------+---------------------------+--------------------------+
| Key | Name | Is enabled | Expiration date | Roles |
+------------------+------+------------+---------------------------+--------------------------+
| valid_api_key | - | +++ | - | Admin |
+------------------+------+------------+---------------------------+--------------------------+
| disabled_api_key | - | --- | - | Admin |
+------------------+------+------------+---------------------------+--------------------------+
| expired_api_key | - | --- | {$expiredApiKeyDate} | Admin |
+------------------+------+------------+---------------------------+--------------------------+
| author_api_key | - | +++ | - | Author only |
+------------------+------+------------+---------------------------+--------------------------+
| domain_api_key | - | +++ | - | Domain only: example.com |
+------------------+------+------------+---------------------------+--------------------------+
+--------------------+------+------------+---------------------------+--------------------------+
| Key | Name | Is enabled | Expiration date | Roles |
+--------------------+------+------------+---------------------------+--------------------------+
| valid_api_key | - | +++ | - | Admin |
+--------------------+------+------------+---------------------------+--------------------------+
| disabled_api_key | - | --- | - | Admin |
+--------------------+------+------------+---------------------------+--------------------------+
| expired_api_key | - | --- | {$expiredApiKeyDate} | Admin |
+--------------------+------+------------+---------------------------+--------------------------+
| author_api_key | - | +++ | - | Author only |
+--------------------+------+------------+---------------------------+--------------------------+
| domain_api_key | - | +++ | - | Domain only: example.com |
+--------------------+------+------------+---------------------------+--------------------------+
| no_orphans_api_key | - | +++ | - | No orphan visits |
+--------------------+------+------------+---------------------------+--------------------------+
OUT];
yield '-e' => [['-e'], $enabledOnlyOutput];

View file

@ -10,20 +10,18 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\DisableKeyCommand;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class DisableKeyCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
protected function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DisableKeyCommand($this->apiKeyService));
$this->commandTester = CliTestUtils::testerForCommand(new DisableKeyCommand($this->apiKeyService));
}
#[Test]

View file

@ -13,14 +13,12 @@ use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class GenerateKeyCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
@ -31,7 +29,7 @@ class GenerateKeyCommandTest extends TestCase
$roleResolver->method('determineRoles')->with($this->isInstanceOf(InputInterface::class))->willReturn([]);
$command = new GenerateKeyCommand($this->apiKeyService, $roleResolver);
$this->commandTester = $this->testerForCommand($command);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]

View file

@ -11,21 +11,19 @@ use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Api\InitialApiKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class InitialApiKeyCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
public function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new InitialApiKeyCommand($this->apiKeyService));
$this->commandTester = CliTestUtils::testerForCommand(new InitialApiKeyCommand($this->apiKeyService));
}
#[Test, DataProvider('provideParams')]

View file

@ -15,20 +15,18 @@ use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class ListKeysCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ApiKeyServiceInterface $apiKeyService;
protected function setUp(): void
{
$this->apiKeyService = $this->createMock(ApiKeyServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListKeysCommand($this->apiKeyService));
$this->commandTester = CliTestUtils::testerForCommand(new ListKeysCommand($this->apiKeyService));
}
#[Test, DataProvider('provideKeysAndOutputs')]

View file

@ -18,7 +18,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
@ -27,8 +27,6 @@ use Symfony\Component\Process\PhpExecutableFinder;
class CreateDatabaseCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ProcessRunnerInterface $processHelper;
private MockObject & Connection $regularConn;
@ -63,7 +61,7 @@ class CreateDatabaseCommandTest extends TestCase
$noDbNameConn->method('createSchemaManager')->withAnyParameters()->willReturn($this->schemaManager);
$command = new CreateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder, $em, $noDbNameConn);
$this->commandTester = $this->testerForCommand($command);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]

View file

@ -9,7 +9,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Lock\LockFactory;
@ -18,8 +18,6 @@ use Symfony\Component\Process\PhpExecutableFinder;
class MigrateDatabaseCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ProcessRunnerInterface $processHelper;
@ -36,7 +34,7 @@ class MigrateDatabaseCommandTest extends TestCase
$this->processHelper = $this->createMock(ProcessRunnerInterface::class);
$command = new MigrateDatabaseCommand($locker, $this->processHelper, $phpExecutableFinder);
$this->commandTester = $this->testerForCommand($command);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]

View file

@ -14,22 +14,20 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function substr_count;
class DomainRedirectsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & DomainServiceInterface $domainService;
protected function setUp(): void
{
$this->domainService = $this->createMock(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DomainRedirectsCommand($this->domainService));
$this->commandTester = CliTestUtils::testerForCommand(new DomainRedirectsCommand($this->domainService));
}
#[Test, DataProvider('provideDomains')]

View file

@ -17,13 +17,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class GetDomainVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
@ -33,7 +31,7 @@ class GetDomainVisitsCommandTest extends TestCase
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
$this->commandTester = CliTestUtils::testerForCommand(
new GetDomainVisitsCommand($this->visitsHelper, $this->stringifier),
);
}

View file

@ -15,20 +15,18 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class ListDomainsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & DomainServiceInterface $domainService;
protected function setUp(): void
{
$this->domainService = $this->createMock(DomainServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListDomainsCommand($this->domainService));
$this->commandTester = CliTestUtils::testerForCommand(new ListDomainsCommand($this->domainService));
}
#[Test, DataProvider('provideInputsAndOutputs')]

View file

@ -20,14 +20,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Tester\CommandTester;
class CreateShortUrlCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & UrlShortenerInterface $urlShortener;
private MockObject & ShortUrlStringifierInterface $stringifier;
@ -45,7 +43,7 @@ class CreateShortUrlCommandTest extends TestCase
defaultShortCodesLength: 5,
),
);
$this->commandTester = $this->testerForCommand($command);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]

View file

@ -12,7 +12,7 @@ use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function sprintf;
@ -21,15 +21,13 @@ use const PHP_EOL;
class DeleteShortUrlCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & DeleteShortUrlServiceInterface $service;
protected function setUp(): void
{
$this->service = $this->createMock(DeleteShortUrlServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteShortUrlCommand($this->service));
$this->commandTester = CliTestUtils::testerForCommand(new DeleteShortUrlCommand($this->service));
}
#[Test]

View file

@ -13,20 +13,18 @@ use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteShortUrlVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ShortUrlVisitsDeleterInterface $deleter;
protected function setUp(): void
{
$this->deleter = $this->createMock(ShortUrlVisitsDeleterInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter));
$this->commandTester = CliTestUtils::testerForCommand(new DeleteShortUrlVisitsCommand($this->deleter));
}
#[Test, DataProvider('provideCancellingInputs')]

View file

@ -20,7 +20,7 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function Shlinkio\Shlink\Common\buildDateRange;
@ -28,8 +28,6 @@ use function sprintf;
class GetShortUrlVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
@ -37,7 +35,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
{
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$command = new GetShortUrlVisitsCommand($this->visitsHelper);
$this->commandTester = $this->testerForCommand($command);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]

View file

@ -21,7 +21,7 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function count;
@ -29,8 +29,6 @@ use function explode;
class ListShortUrlsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ShortUrlListServiceInterface $shortUrlService;
@ -40,7 +38,7 @@ class ListShortUrlsCommandTest extends TestCase
$command = new ListShortUrlsCommand($this->shortUrlService, new ShortUrlDataTransformer(
new ShortUrlStringifier([]),
));
$this->commandTester = $this->testerForCommand($command);
$this->commandTester = CliTestUtils::testerForCommand($command);
}
#[Test]

View file

@ -12,7 +12,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function sprintf;
@ -21,15 +21,13 @@ use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & ShortUrlResolverInterface $urlResolver;
protected function setUp(): void
{
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
$this->commandTester = $this->testerForCommand(new ResolveUrlCommand($this->urlResolver));
$this->commandTester = CliTestUtils::testerForCommand(new ResolveUrlCommand($this->urlResolver));
}
#[Test]

View file

@ -9,20 +9,18 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteTagsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & TagServiceInterface $tagService;
protected function setUp(): void
{
$this->tagService = $this->createMock(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteTagsCommand($this->tagService));
$this->commandTester = CliTestUtils::testerForCommand(new DeleteTagsCommand($this->tagService));
}
#[Test]

View file

@ -17,13 +17,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class GetTagVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
@ -33,7 +31,7 @@ class GetTagVisitsCommandTest extends TestCase
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
$this->commandTester = CliTestUtils::testerForCommand(
new GetTagVisitsCommand($this->visitsHelper, $this->stringifier),
);
}

View file

@ -12,20 +12,18 @@ use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class ListTagsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & TagServiceInterface $tagService;
protected function setUp(): void
{
$this->tagService = $this->createMock(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new ListTagsCommand($this->tagService));
$this->commandTester = CliTestUtils::testerForCommand(new ListTagsCommand($this->tagService));
}
#[Test]

View file

@ -12,20 +12,18 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class RenameTagCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & TagServiceInterface $tagService;
protected function setUp(): void
{
$this->tagService = $this->createMock(TagServiceInterface::class);
$this->commandTester = $this->testerForCommand(new RenameTagCommand($this->tagService));
$this->commandTester = CliTestUtils::testerForCommand(new RenameTagCommand($this->tagService));
}
#[Test]

View file

@ -11,20 +11,18 @@ use Shlinkio\Shlink\CLI\Command\Visit\DeleteOrphanVisitsCommand;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use Shlinkio\Shlink\Core\Model\BulkDeleteResult;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class DeleteOrphanVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & VisitsDeleterInterface $deleter;
protected function setUp(): void
{
$this->deleter = $this->createMock(VisitsDeleterInterface::class);
$this->commandTester = $this->testerForCommand(new DeleteOrphanVisitsCommand($this->deleter));
$this->commandTester = CliTestUtils::testerForCommand(new DeleteOrphanVisitsCommand($this->deleter));
}
#[Test]

View file

@ -13,22 +13,20 @@ use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
use Shlinkio\Shlink\CLI\Util\ExitCode;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function sprintf;
class DownloadGeoLiteDbCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & GeolocationDbUpdaterInterface $dbUpdater;
protected function setUp(): void
{
$this->dbUpdater = $this->createMock(GeolocationDbUpdaterInterface::class);
$this->commandTester = $this->testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater));
$this->commandTester = CliTestUtils::testerForCommand(new DownloadGeoLiteDbCommand($this->dbUpdater));
}
#[Test, DataProvider('provideFailureParams')]
@ -74,6 +72,21 @@ class DownloadGeoLiteDbCommandTest extends TestCase
];
}
#[Test]
public function warningIsPrintedWhenLicenseIsMissing(): void
{
$this->dbUpdater->expects($this->once())->method('checkDbUpdate')->withAnyParameters()->willReturn(
GeolocationResult::LICENSE_MISSING,
);
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode();
self::assertStringContainsString('[WARNING] It was not possible to download GeoLite2 db', $output);
self::assertSame(ExitCode::EXIT_WARNING, $exitCode);
}
#[Test, DataProvider('provideSuccessParams')]
public function printsExpectedMessageWhenNoErrorOccurs(callable $checkUpdateBehavior, string $expectedMessage): void
{

View file

@ -17,13 +17,11 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class GetNonOrphanVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
private MockObject & ShortUrlStringifierInterface $stringifier;
@ -33,7 +31,7 @@ class GetNonOrphanVisitsCommandTest extends TestCase
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->stringifier = $this->createMock(ShortUrlStringifierInterface::class);
$this->commandTester = $this->testerForCommand(
$this->commandTester = CliTestUtils::testerForCommand(
new GetNonOrphanVisitsCommand($this->visitsHelper, $this->stringifier),
);
}

View file

@ -15,20 +15,18 @@ use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
class GetOrphanVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & VisitsStatsHelperInterface $visitsHelper;
protected function setUp(): void
{
$this->visitsHelper = $this->createMock(VisitsStatsHelperInterface::class);
$this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper));
$this->commandTester = CliTestUtils::testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper));
}
#[Test]

View file

@ -21,7 +21,7 @@ use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelperInterface;
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Output\OutputInterface;
@ -34,8 +34,6 @@ use const PHP_EOL;
class LocateVisitsCommandTest extends TestCase
{
use CliTestUtilsTrait;
private CommandTester $commandTester;
private MockObject & VisitLocatorInterface $visitService;
private MockObject & VisitToLocationHelperInterface $visitToLocation;
@ -53,8 +51,8 @@ class LocateVisitsCommandTest extends TestCase
$command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker);
$this->downloadDbCommand = $this->createCommandMock(DownloadGeoLiteDbCommand::NAME);
$this->commandTester = $this->testerForCommand($command, $this->downloadDbCommand);
$this->downloadDbCommand = CliTestUtils::createCommandMock(DownloadGeoLiteDbCommand::NAME);
$this->commandTester = CliTestUtils::testerForCommand($command, $this->downloadDbCommand);
}
#[Test, DataProvider('provideArgs')]

View file

@ -9,12 +9,10 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Core\Options\AppOptions;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
class ApplicationFactoryTest extends TestCase
{
use CliTestUtilsTrait;
private ApplicationFactory $factory;
protected function setUp(): void
@ -32,8 +30,8 @@ class ApplicationFactoryTest extends TestCase
'baz' => 'baz',
],
]);
$sm->setService('foo', $this->createCommandMock('foo'));
$sm->setService('bar', $this->createCommandMock('bar'));
$sm->setService('foo', CliTestUtils::createCommandMock('foo'));
$sm->setService('bar', CliTestUtils::createCommandMock('bar'));
$instance = ($this->factory)($sm);

View file

@ -16,6 +16,7 @@ use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationResult;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\IpGeolocation\Exception\DbUpdateException;
use Shlinkio\Shlink\IpGeolocation\Exception\MissingLicenseException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock;
use Throwable;
@ -37,6 +38,21 @@ class GeolocationDbUpdaterTest extends TestCase
$this->lock->method('acquire')->with($this->isTrue())->willReturn(true);
}
#[Test]
public function properResultIsReturnedWhenLicenseIsMissing(): void
{
$mustBeUpdated = fn () => self::assertTrue(true);
$this->dbUpdater->expects($this->once())->method('databaseFileExists')->willReturn(false);
$this->dbUpdater->expects($this->once())->method('downloadFreshCopy')->willThrowException(
new MissingLicenseException(''),
);
$this->geoLiteDbReader->expects($this->never())->method('metadata');
$result = $this->geolocationDbUpdater()->checkDbUpdate($mustBeUpdated);
self::assertEquals(GeolocationResult::LICENSE_MISSING, $result);
}
#[Test]
public function exceptionIsThrownWhenOlderDbDoesNotExistAndDownloadFails(): void
{

View file

@ -2,20 +2,34 @@
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI;
namespace ShlinkioTest\Shlink\CLI\Util;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\Generator\Generator;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Tester\CommandTester;
trait CliTestUtilsTrait
class CliTestUtils
{
private function createCommandMock(string $name): MockObject & Command
public static function createCommandMock(string $name): MockObject & Command
{
$command = $this->createMock(Command::class);
static $generator = null;
if ($generator === null) {
$generator = new Generator();
}
$command = $generator->testDouble(
Command::class,
mockObject: true,
callOriginalConstructor: false,
callOriginalClone: false,
cloneArguments: false,
allowMockingUnknownTypes: false,
);
$command->method('getName')->willReturn($name);
$command->method('isEnabled')->willReturn(true);
$command->method('getAliases')->willReturn([]);
@ -25,7 +39,7 @@ trait CliTestUtilsTrait
return $command;
}
private function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester
public static function testerForCommand(Command $mainCommand, Command ...$extraCommands): CommandTester
{
$app = new Application();
$app->add($mainCommand);

View file

@ -9,7 +9,6 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Doctrine\EntityRepositoryFactory;
use Shlinkio\Shlink\Config\Factory\ValinorConfigFactory;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
@ -93,6 +92,9 @@ return [
Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class,
Crawling\CrawlingHelper::class => ConfigAbstractFactory::class,
Matomo\MatomoOptions::class => [ValinorConfigFactory::class, 'config.matomo'],
Matomo\MatomoTrackerBuilder::class => ConfigAbstractFactory::class,
],
'aliases' => [
@ -101,6 +103,8 @@ return [
],
ConfigAbstractFactory::class => [
Matomo\MatomoTrackerBuilder::class => [Matomo\MatomoOptions::class],
ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\RequestTracker::class],
ErrorHandler\NotFoundRedirectHandler::class => [

View file

@ -9,144 +9,189 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\GeoLite\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper;
use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper;
use Shlinkio\Shlink\Common\Mercure\MercureOptions;
use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitLocator;
use Shlinkio\Shlink\Core\Visit\Geolocation\VisitToLocationHelper;
use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
return [
use function Shlinkio\Shlink\Config\runningInOpenswoole;
use function Shlinkio\Shlink\Config\runningInRoadRunner;
'events' => [
'regular' => [
EventDispatcher\Event\UrlVisited::class => [
EventDispatcher\LocateVisit::class,
],
EventDispatcher\Event\GeoLiteDbCreated::class => [
EventDispatcher\LocateUnlocatedVisits::class,
],
return (static function (): array {
$regularEvents = [
EventDispatcher\Event\UrlVisited::class => [
EventDispatcher\LocateVisit::class,
],
'async' => [
EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
EventDispatcher\NotifyVisitToWebHooks::class,
EventDispatcher\UpdateGeoLiteDb::class,
],
EventDispatcher\Event\ShortUrlCreated::class => [
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class,
],
EventDispatcher\Event\GeoLiteDbCreated::class => [
EventDispatcher\LocateUnlocatedVisits::class,
],
],
];
$asyncEvents = [
EventDispatcher\Event\VisitLocated::class => [
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
EventDispatcher\NotifyVisitToWebHooks::class,
EventDispatcher\UpdateGeoLiteDb::class,
],
EventDispatcher\Event\ShortUrlCreated::class => [
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class,
],
];
'dependencies' => [
'factories' => [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class,
// Send visits to matomo asynchronously if the runtime allows it
if (runningInRoadRunner() || runningInOpenswoole()) {
$asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class;
} else {
$regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class];
}
return [
'events' => [
'regular' => $regularEvents,
'async' => $asyncEvents,
],
'delegators' => [
'dependencies' => [
'factories' => [
EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class,
EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class,
EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class,
EventDispatcher\Helper\EnabledListenerChecker::class => ConfigAbstractFactory::class,
],
'aliases' => [
EnabledListenerCheckerInterface::class => EventDispatcher\Helper\EnabledListenerChecker::class,
],
'delegators' => [
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],
ConfigAbstractFactory::class => [
EventDispatcher\LocateVisit::class => [
IpLocationResolverInterface::class,
'em',
'Logger_Shlink',
DbUpdater::class,
EventDispatcherInterface::class,
],
EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class],
EventDispatcher\NotifyVisitToWebHooks::class => [
'httpClient',
'em',
'Logger_Shlink',
Options\WebhookOptions::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Options\AppOptions::class,
],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Visit\Transformer\OrphanVisitDataTransformer::class,
Options\RabbitMqOptions::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Options\RabbitMqOptions::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\LocateUnlocatedVisits::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
EventDispatcher\NotifyVisitToWebHooks::class => [
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
],
],
],
ConfigAbstractFactory::class => [
EventDispatcher\LocateVisit::class => [
IpLocationResolverInterface::class,
'em',
'Logger_Shlink',
DbUpdater::class,
EventDispatcherInterface::class,
],
EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class],
EventDispatcher\NotifyVisitToWebHooks::class => [
'httpClient',
'em',
'Logger_Shlink',
Options\WebhookOptions::class,
ShortUrl\Transformer\ShortUrlDataTransformer::class,
Options\AppOptions::class,
],
EventDispatcher\Mercure\NotifyVisitToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [
MercureHubPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Visit\Transformer\OrphanVisitDataTransformer::class,
Options\RabbitMqOptions::class,
],
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [
RabbitMqPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
Options\RabbitMqOptions::class,
],
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [
RedisPublishingHelper::class,
EventDispatcher\PublishingUpdatesGenerator::class,
'em',
'Logger_Shlink',
'config.redis.pub_sub_enabled',
],
EventDispatcher\UpdateGeoLiteDb::class => [
GeolocationDbUpdater::class,
'Logger_Shlink',
EventDispatcherInterface::class,
],
],
EventDispatcher\Matomo\SendVisitToMatomo::class => [
'em',
'Logger_Shlink',
ShortUrlStringifier::class,
Matomo\MatomoOptions::class,
Matomo\MatomoTrackerBuilder::class,
],
];
EventDispatcher\UpdateGeoLiteDb::class => [
GeolocationDbUpdater::class,
'Logger_Shlink',
EventDispatcherInterface::class,
],
EventDispatcher\Helper\EnabledListenerChecker::class => [
Options\RabbitMqOptions::class,
'config.redis.pub_sub_enabled',
MercureOptions::class,
Options\WebhookOptions::class,
GeoLite2Options::class,
MatomoOptions::class,
],
],
];
})();

View file

@ -17,25 +17,32 @@ enum EnvVars: string
case DB_UNIX_SOCKET = 'DB_UNIX_SOCKET';
case DB_PORT = 'DB_PORT';
case GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY';
case CACHE_NAMESPACE = 'CACHE_NAMESPACE';
case REDIS_SERVERS = 'REDIS_SERVERS';
case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE';
case REDIS_DECODE_CREDENTIALS = 'REDIS_DECODE_CREDENTIALS';
case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED';
case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL';
case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL';
case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET';
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case RABBITMQ_ENABLED = 'RABBITMQ_ENABLED';
case RABBITMQ_HOST = 'RABBITMQ_HOST';
case RABBITMQ_PORT = 'RABBITMQ_PORT';
case RABBITMQ_USER = 'RABBITMQ_USER';
case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD';
case RABBITMQ_VHOST = 'RABBITMQ_VHOST';
case RABBITMQ_USE_SSL = 'RABBITMQ_USE_SSL';
/** @deprecated */
case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING';
case MATOMO_ENABLED = 'MATOMO_ENABLED';
case MATOMO_BASE_URL = 'MATOMO_BASE_URL';
case MATOMO_SITE_ID = 'MATOMO_SITE_ID';
case MATOMO_API_TOKEN = 'MATOMO_API_TOKEN';
case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE';
case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN';
case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT';
case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION';
case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE';
case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT';
case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT';
case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT';

View file

@ -79,6 +79,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe
yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) {
Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))],
Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)],
default => null,
}) ?? [];
}
}

View file

@ -9,17 +9,19 @@ use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable;
abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializable
{
final public function __construct(public readonly string $visitId)
{
final public function __construct(
public readonly string $visitId,
public readonly ?string $originalIpAddress = null,
) {
}
public function jsonSerialize(): array
{
return ['visitId' => $this->visitId];
return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress];
}
public static function fromPayload(array $payload): self
{
return new static($payload['visitId'] ?? '');
return new static($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null);
}
}

View file

@ -6,18 +6,4 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
final class UrlVisited extends AbstractVisitEvent
{
private ?string $originalIpAddress = null;
public static function withOriginalIpAddress(string $visitId, ?string $originalIpAddress): self
{
$instance = new self($visitId);
$instance->originalIpAddress = $originalIpAddress;
return $instance;
}
public function originalIpAddress(): ?string
{
return $this->originalIpAddress;
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Helper;
use Shlinkio\Shlink\Common\Mercure\MercureOptions;
use Shlinkio\Shlink\Core\EventDispatcher;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Options\RabbitMqOptions;
use Shlinkio\Shlink\Core\Options\WebhookOptions;
use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options;
class EnabledListenerChecker implements EnabledListenerCheckerInterface
{
public function __construct(
private readonly RabbitMqOptions $rabbitMqOptions,
private readonly bool $redisPubSubEnabled,
private readonly MercureOptions $mercureOptions,
private readonly WebhookOptions $webhookOptions,
private readonly GeoLite2Options $geoLiteOptions,
private readonly MatomoOptions $matomoOptions,
) {
}
public function shouldRegisterListener(string $event, string $listener, bool $isAsync): bool
{
if (! $isAsync) {
return true;
}
return match ($listener) {
EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class,
EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => $this->rabbitMqOptions->enabled,
EventDispatcher\RedisPubSub\NotifyVisitToRedis::class,
EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => $this->redisPubSubEnabled,
EventDispatcher\Mercure\NotifyVisitToMercure::class,
EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(),
EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled,
EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(),
EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(),
default => false, // Any unknown async listener should not be enabled by default
};
}
}

View file

@ -41,8 +41,8 @@ class LocateVisit
return;
}
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit);
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit);
$this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress));
}
private function locateVisit(string $visitId, ?string $originalIpAddress, Visit $visit): void

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Matomo;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoTrackerBuilderInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable;
class SendVisitToMatomo
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly ShortUrlStringifier $shortUrlStringifier,
private readonly MatomoOptions $matomoOptions,
private readonly MatomoTrackerBuilderInterface $trackerBuilder,
) {
}
public function __invoke(VisitLocated $visitLocated): void
{
if (! $this->matomoOptions->enabled) {
return;
}
$visitId = $visitLocated->visitId;
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to send visit with id "{visitId}" to matomo, but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
try {
$tracker = $this->trackerBuilder->buildMatomoTracker();
$tracker
->setUrl($this->resolveUrlToTrack($visit))
->setCustomTrackingParameter('type', $visit->type()->value)
->setUserAgent($visit->userAgent())
->setUrlReferrer($visit->referer());
$location = $visit->getVisitLocation();
if ($location !== null) {
$tracker
->setCity($location->getCityName())
->setCountry($location->getCountryName())
->setLatitude($location->getLatitude())
->setLongitude($location->getLongitude());
}
// Set not obfuscated IP if possible, as matomo handles obfuscation itself
$ip = $visitLocated->originalIpAddress ?? $visit->getRemoteAddr();
if ($ip !== null) {
$tracker->setIp($ip);
}
if ($visit->isOrphan()) {
$tracker->setCustomTrackingParameter('orphan', 'true');
}
// Send empty document title to avoid different actions to be created by matomo
$tracker->doTrackPageView('');
} catch (Throwable $e) {
// Capture all exceptions to make sure this does not interfere with the regular execution
$this->logger->error('An error occurred while trying to send visit to Matomo. {e}', ['e' => $e]);
}
}
public function resolveUrlToTrack(Visit $visit): string
{
$shortUrl = $visit->getShortUrl();
if ($shortUrl === null) {
return $visit->visitedUrl() ?? '';
}
return $this->shortUrlStringifier->stringify($shortUrl);
}
}

View file

@ -139,7 +139,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
$importedVisits = 0;
foreach ($iterable as $importedOrphanVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentOrphanVisit?->getDate()->gte(normalizeDate($importedOrphanVisit->date))) {
if ($mostRecentOrphanVisit?->getDate()->greaterThanOrEquals(normalizeDate($importedOrphanVisit->date))) {
continue;
}

View file

@ -38,7 +38,7 @@ final class ShortUrlImporting
$importedVisits = 0;
foreach ($visits as $importedVisit) {
// Skip visits which are older than the most recent already imported visit's date
if ($mostRecentImportedDate?->gte(normalizeDate($importedVisit->date))) {
if ($mostRecentImportedDate?->greaterThanOrEquals(normalizeDate($importedVisit->date))) {
continue;
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
class MatomoOptions
{
public function __construct(
public readonly bool $enabled = false,
public readonly ?string $baseUrl = null,
/** @var numeric-string|int|null */
private readonly string|int|null $siteId = null,
public readonly ?string $apiToken = null,
) {
}
public function siteId(): ?int
{
if ($this->siteId === null) {
return null;
}
// We enforce site ID to be hydrated as a numeric string or int, so it's safe to cast to int here
return (int) $this->siteId;
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Matomo;
use MatomoTracker;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
class MatomoTrackerBuilder implements MatomoTrackerBuilderInterface
{
public const MATOMO_DEFAULT_TIMEOUT = 10; // Time in seconds
public function __construct(private readonly MatomoOptions $options)
{
}
/**
* @throws RuntimeException If there's any missing matomo parameter
*/
public function buildMatomoTracker(): MatomoTracker
{
$siteId = $this->options->siteId();
if ($siteId === null || $this->options->baseUrl === null || $this->options->apiToken === null) {
throw new RuntimeException(
'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined',
);
}
// Create a new MatomoTracker on every request, because it infers request info during construction
$tracker = new MatomoTracker($siteId, $this->options->baseUrl);
$tracker
// Token required to set the IP and location
->setTokenAuth($this->options->apiToken)
// Ensure params are not sent in the URL, for security reasons
->setRequestMethodNonBulk('POST')
// Set a reasonable timeout
->setRequestTimeout(self::MATOMO_DEFAULT_TIMEOUT)
->setRequestConnectTimeout(self::MATOMO_DEFAULT_TIMEOUT);
// We don't want to bulk send, as every request to Shlink will create a new tracker
$tracker->disableBulkTracking();
// Disable cookies, as they are ignored anyway
$tracker->disableCookieSupport();
return $tracker;
}
}

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