diff --git a/.github/DISCUSSION_TEMPLATE/help-wanted.yml b/.github/DISCUSSION_TEMPLATE/help-wanted.yml new file mode 100644 index 00000000..1283f43d --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -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: '' + diff --git a/.github/ISSUE_TEMPLATE/Bug.md b/.github/ISSUE_TEMPLATE/Bug.md deleted file mode 100644 index 9d8b644c..00000000 --- a/.github/ISSUE_TEMPLATE/Bug.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Something on shlink is broken or not working as documented? -labels: bug ---- - - - -#### 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 - - - -#### Current behavior - - - -#### Expected behavior - - - -#### How to reproduce - - diff --git a/.github/ISSUE_TEMPLATE/Bug.yml b/.github/ISSUE_TEMPLATE/Bug.yml new file mode 100644 index 00000000..1f715088 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug.yml @@ -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: '' + - type: textarea + validations: + required: true + attributes: + label: Expected behavior + value: '' + - type: textarea + validations: + required: true + attributes: + label: How to reproduce + value: '' diff --git a/.github/ISSUE_TEMPLATE/Feature_Request.md b/.github/ISSUE_TEMPLATE/Feature_Request.md deleted file mode 100644 index dcfc37ad..00000000 --- a/.github/ISSUE_TEMPLATE/Feature_Request.md +++ /dev/null @@ -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 ---- - - - -#### Summary - - diff --git a/.github/ISSUE_TEMPLATE/Feature_Request.yml b/.github/ISSUE_TEMPLATE/Feature_Request.yml new file mode 100644 index 00000000..4112f75a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_Request.yml @@ -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: '' + - type: textarea + validations: + required: true + attributes: + label: Use case + value: '' diff --git a/.github/ISSUE_TEMPLATE/Question_Support.md b/.github/ISSUE_TEMPLATE/Question_Support.md deleted file mode 100644 index 28e5e022..00000000 --- a/.github/ISSUE_TEMPLATE/Question_Support.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: Question - Support -about: Do you have a problem setting up or using shlink? -labels: question ---- - - - -#### 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 - - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..53fca8ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 19df378a..054575eb 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -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 diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index 0c77ded6..f164e7da 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -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: | diff --git a/.github/workflows/ci-docker-image-build.yml b/.github/workflows/ci-docker-image-build.yml index 3a055f10..43812fad 100644 --- a/.github/workflows/ci-docker-image-build.yml +++ b/.github/workflows/ci-docker-image-build.yml @@ -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 . diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index 20e5aefa..6bc69eb3 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -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: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 043a3824..62b7ca2e 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdc5a025..37134b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index 3dda2ead..ee9276fd 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -2,16 +2,6 @@ name: Build and publish docker image on: push: - paths-ignore: - - 'LICENSE' - - '.*' - - '*.md' - - '*.xml' - - '*.yml*' - - '*.json5' - - '*.neon' - branches: - - develop tags: - 'v*' diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index bda463a9..625597a1 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -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 diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 06b33566..2ecf8d49 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -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' diff --git a/.gitignore b/.gitignore index 283d5b7f..b07b73d1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc26f69..2b0d0d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [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* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index feb437ad..d21c577a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/Dockerfile b/Dockerfile index 4637e09e..0916b10b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ; \ diff --git a/README.md b/README.md index e86c5156..ee27f030 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 1cbf948a..b22a974e 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -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"}" diff --git a/build.sh b/build.sh index 43b240a2..db607172 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/composer.json b/composer.json index 9c784a06..0e1b996e 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/config/autoload/cache.global.php b/config/autoload/cache.global.php index 614b140f..30db2c0a 100644 --- a/config/autoload/cache.global.php +++ b/config/autoload/cache.global.php @@ -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, diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 966e36ee..32f71ea6 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -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' => [ diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 1820c480..01ec40ab 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -52,6 +52,7 @@ return (static function (): array { ], ], + // Deprecated. Remove in Shlink 4.0.0 'mezzio-swoole' => [ 'swoole-http-server' => [ 'logger' => [ diff --git a/config/autoload/matomo.global.php b/config/autoload/matomo.global.php new file mode 100644 index 00000000..120ad289 --- /dev/null +++ b/config/autoload/matomo.global.php @@ -0,0 +1,16 @@ + [ + '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(), + ], + +]; diff --git a/config/autoload/matomo.local.php.dist b/config/autoload/matomo.local.php.dist new file mode 100644 index 00000000..2a940407 --- /dev/null +++ b/config/autoload/matomo.local.php.dist @@ -0,0 +1,26 @@ + [ +// 'enabled' => true, +// 'base_url' => 'http://shlink_matomo', +// 'site_id' => '...', +// 'api_token' => '...', + ], + +]; diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index ea003809..bf9591e5 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -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(), diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index ea305d86..051e18dd 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -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(), diff --git a/config/cli-config.php b/config/cli-config.php index 52659e4e..57348824 100644 --- a/config/cli-config.php +++ b/config/cli-config.php @@ -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), + ); })(); diff --git a/config/config.php b/config/config.php index 9df29138..a52ade5a 100644 --- a/config/config.php +++ b/config/config.php @@ -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(); diff --git a/config/container.php b/config/container.php index 6813ebd4..e7574fe6 100644 --- a/config/container.php +++ b/config/container.php @@ -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 diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 6cd4dd4e..0cd3ff4b 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -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; } diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 90ccab23..14c99f95 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -2,7 +2,7 @@ FROM php:8.2-fpm-alpine3.17 MAINTAINER Alejandro Celaya 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 diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 457a416f..0e91d491 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -2,7 +2,7 @@ FROM php:8.2-alpine3.17 MAINTAINER Alejandro Celaya 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 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 42c27b14..72536c75 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -3,8 +3,8 @@ MAINTAINER Alejandro Celaya 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 diff --git a/docker-compose.yml b/docker-compose.yml index ca0064b4..e44ca82b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/docker/README.md b/docker/README.md index 629a9ee1..13de359d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 06cb8ec4..799feb80 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -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 diff --git a/docs/adr/2023-07-09-build-latest-docker-image-only-for-actual-releases.md b/docs/adr/2023-07-09-build-latest-docker-image-only-for-actual-releases.md new file mode 100644 index 00000000..aad742f9 --- /dev/null +++ b/docs/adr/2023-07-09-build-latest-docker-image-only-for-actual-releases.md @@ -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 `-. +* 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9d87a0fb..bafb80b5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/docs/swagger/definitions/DeviceLongUrls.json b/docs/swagger/definitions/DeviceLongUrls.json index 1a56d9ef..0e8719db 100644 --- a/docs/swagger/definitions/DeviceLongUrls.json +++ b/docs/swagger/definitions/DeviceLongUrls.json @@ -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"] } } } diff --git a/docs/swagger/definitions/DeviceLongUrlsEdit.json b/docs/swagger/definitions/DeviceLongUrlsEdit.json index 78f77e46..f1ff255f 100644 --- a/docs/swagger/definitions/DeviceLongUrlsEdit.json +++ b/docs/swagger/definitions/DeviceLongUrlsEdit.json @@ -5,13 +5,13 @@ }], "properties": { "android": { - "nullable": true + "type": ["null"] }, "ios": { - "nullable": true + "type": ["null"] }, "desktop": { - "nullable": true + "type": ["null"] } } } diff --git a/docs/swagger/definitions/NotFoundRedirects.json b/docs/swagger/definitions/NotFoundRedirects.json index 6887ed0c..d0459f90 100644 --- a/docs/swagger/definitions/NotFoundRedirects.json +++ b/docs/swagger/definitions/NotFoundRedirects.json @@ -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" } } diff --git a/docs/swagger/definitions/OrphanVisit.json b/docs/swagger/definitions/OrphanVisit.json index 04d8386d..a8b4954a 100644 --- a/docs/swagger/definitions/OrphanVisit.json +++ b/docs/swagger/definitions/OrphanVisit.json @@ -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": { diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 4060e2f2..98fd9c87 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -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": { diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index ed3c3929..dda213ca 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -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", diff --git a/docs/swagger/definitions/ShortUrlMeta.json b/docs/swagger/definitions/ShortUrlMeta.json index 370a548b..d687d97f 100644 --- a/docs/swagger/definitions/ShortUrlMeta.json +++ b/docs/swagger/definitions/ShortUrlMeta.json @@ -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"] } } } diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index b80ae3b2..51655ecf 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -1,5 +1,5 @@ { - "openapi": "3.0.3", + "openapi": "3.1.0", "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", diff --git a/migrations.php b/migrations.php deleted file mode 100644 index 306c1c08..00000000 --- a/migrations.php +++ /dev/null @@ -1,15 +0,0 @@ - [ - 'ShlinkMigrations' => 'data/migrations', - ], - 'table_storage' => [ - 'table_name' => 'migrations', - ], - 'custom_template' => 'data/migrations_template.txt', - -]; diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index ad98bde4..76787451 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -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 diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 85f709f8..1fe2f996 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -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 = <<%command.name% generates a new valid API key. @@ -53,7 +55,8 @@ class GenerateKeyCommand extends Command * Can interact with short URLs created with this API key: %command.full_name% --{$authorOnly} * Can interact with short URLs for one domain: %command.full_name% --{$domainOnly}=example.com - * Both: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com + * Cannot see orphan visits: %command.full_name% --{$noOrphanVisits} + * All: %command.full_name% --{$authorOnly} --{$domainOnly}=example.com --{$noOrphanVisits} 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); } diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 87b239b7..4fd4b005 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -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; diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 23600530..8da6c753 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -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('%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; + } } diff --git a/module/CLI/src/GeoLite/GeolocationDbUpdater.php b/module/CLI/src/GeoLite/GeolocationDbUpdater.php index f33b8796..b377c14b 100644 --- a/module/CLI/src/GeoLite/GeolocationDbUpdater.php +++ b/module/CLI/src/GeoLite/GeolocationDbUpdater.php @@ -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) diff --git a/module/CLI/src/GeoLite/GeolocationResult.php b/module/CLI/src/GeoLite/GeolocationResult.php index 7b245943..85976886 100644 --- a/module/CLI/src/GeoLite/GeolocationResult.php +++ b/module/CLI/src/GeoLite/GeolocationResult.php @@ -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; diff --git a/module/CLI/test-cli/Command/ImportShortUrlsTest.php b/module/CLI/test-cli/Command/ImportShortUrlsTest.php index 3a710af0..1ed15d7c 100644 --- a/module/CLI/test-cli/Command/ImportShortUrlsTest.php +++ b/module/CLI/test-cli/Command/ImportShortUrlsTest.php @@ -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 { diff --git a/module/CLI/test-cli/Command/ListApiKeysTest.php b/module/CLI/test-cli/Command/ListApiKeysTest.php index f8781d54..46e3c135 100644 --- a/module/CLI/test-cli/Command/ListApiKeysTest.php +++ b/module/CLI/test-cli/Command/ListApiKeysTest.php @@ -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 = << [[], << [['-e'], $enabledOnlyOutput]; diff --git a/module/CLI/test/Command/Api/DisableKeyCommandTest.php b/module/CLI/test/Command/Api/DisableKeyCommandTest.php index 8a1c64e8..a12cb46f 100644 --- a/module/CLI/test/Command/Api/DisableKeyCommandTest.php +++ b/module/CLI/test/Command/Api/DisableKeyCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php index aedda4ca..a15ad667 100644 --- a/module/CLI/test/Command/Api/GenerateKeyCommandTest.php +++ b/module/CLI/test/Command/Api/GenerateKeyCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php index e0732aab..482bd36f 100644 --- a/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php +++ b/module/CLI/test/Command/Api/InitialApiKeyCommandTest.php @@ -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')] diff --git a/module/CLI/test/Command/Api/ListKeysCommandTest.php b/module/CLI/test/Command/Api/ListKeysCommandTest.php index 5e246fc7..478dbaa5 100644 --- a/module/CLI/test/Command/Api/ListKeysCommandTest.php +++ b/module/CLI/test/Command/Api/ListKeysCommandTest.php @@ -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')] diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index 420ea91d..cece20db 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index 7bdbfca0..ac4283d7 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php index 48125d91..0bc77aca 100644 --- a/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php +++ b/module/CLI/test/Command/Domain/DomainRedirectsCommandTest.php @@ -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')] diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php index bdee0ed4..7f4bd076 100644 --- a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -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), ); } diff --git a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php index 05cc95eb..cfa09e18 100644 --- a/module/CLI/test/Command/Domain/ListDomainsCommandTest.php +++ b/module/CLI/test/Command/Domain/ListDomainsCommandTest.php @@ -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')] diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 46063485..de0fe26b 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 06081983..0402dc8c 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php index 88c3657a..2a281a8a 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlVisitsCommandTest.php @@ -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')] diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index 13d36f4b..f93ab5ec 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index aff1ebdb..6859ec13 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 21452ed6..9c9bbb93 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php index d818ba54..7bbd5966 100644 --- a/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/DeleteTagsCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php index ef34952d..a2dc059f 100644 --- a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -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), ); } diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index e1020667..1cfb3d3b 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Tag/RenameTagCommandTest.php b/module/CLI/test/Command/Tag/RenameTagCommandTest.php index 7dfe474f..296926b8 100644 --- a/module/CLI/test/Command/Tag/RenameTagCommandTest.php +++ b/module/CLI/test/Command/Tag/RenameTagCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php index c18fe7f4..cd39c63a 100644 --- a/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/DeleteOrphanVisitsCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php index 7e904caa..4d2754f8 100644 --- a/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php +++ b/module/CLI/test/Command/Visit/DownloadGeoLiteDbCommandTest.php @@ -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 { diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php index dabfdb06..439b33bd 100644 --- a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -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), ); } diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index 226cb927..b90e6af6 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -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] diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 6ff8c242..031e8e45 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -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')] diff --git a/module/CLI/test/Factory/ApplicationFactoryTest.php b/module/CLI/test/Factory/ApplicationFactoryTest.php index 3d75c647..83b0fc66 100644 --- a/module/CLI/test/Factory/ApplicationFactoryTest.php +++ b/module/CLI/test/Factory/ApplicationFactoryTest.php @@ -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); diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 2d47d79c..9d32ca79 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -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 { diff --git a/module/CLI/test/CliTestUtilsTrait.php b/module/CLI/test/Util/CliTestUtils.php similarity index 56% rename from module/CLI/test/CliTestUtilsTrait.php rename to module/CLI/test/Util/CliTestUtils.php index 761567ae..9c92f882 100644 --- a/module/CLI/test/CliTestUtilsTrait.php +++ b/module/CLI/test/Util/CliTestUtils.php @@ -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); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index da653406..591fcc79 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -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 => [ diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index e4bf3c0c..1a81d8ed 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -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, + ], + ], + + ]; +})(); diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index ec7d384c..790bfe3a 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -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'; diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index dcbc3d9e..aae44aed 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -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, }) ?? []; } } diff --git a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php index 907b3d9c..87f7dba2 100644 --- a/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php +++ b/module/Core/src/EventDispatcher/Event/AbstractVisitEvent.php @@ -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); } } diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index c57d59d6..d1158a4e 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -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; - } } diff --git a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php new file mode 100644 index 00000000..269aed76 --- /dev/null +++ b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php @@ -0,0 +1,46 @@ + $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 + }; + } +} diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index ba3ac3f0..f139c0f5 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -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 diff --git a/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php new file mode 100644 index 00000000..ad9660cb --- /dev/null +++ b/module/Core/src/EventDispatcher/Matomo/SendVisitToMatomo.php @@ -0,0 +1,89 @@ +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); + } +} diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index ef7633a7..7a9c3b92 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -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; } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index 28c22a24..f806f856 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -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; } diff --git a/module/Core/src/Matomo/MatomoOptions.php b/module/Core/src/Matomo/MatomoOptions.php new file mode 100644 index 00000000..23599321 --- /dev/null +++ b/module/Core/src/Matomo/MatomoOptions.php @@ -0,0 +1,27 @@ +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; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilder.php b/module/Core/src/Matomo/MatomoTrackerBuilder.php new file mode 100644 index 00000000..4bad6799 --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilder.php @@ -0,0 +1,48 @@ +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; + } +} diff --git a/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php new file mode 100644 index 00000000..7601f17a --- /dev/null +++ b/module/Core/src/Matomo/MatomoTrackerBuilderInterface.php @@ -0,0 +1,16 @@ +setUserAgent($userAgent); return match (true) { // $detect->is('iOS') && $detect->isTablet() => self::IOS, // TODO To detect iPad only diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 32b40033..03091d75 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -10,8 +10,10 @@ use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; final class UrlShortenerOptions { + /** + * @param array{schema: ?string, hostname: ?string} $domain + */ public function __construct( - /** @var array{schema: ?string, hostname: ?string} */ public readonly array $domain = ['schema' => null, 'hostname' => null], public readonly int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH, public readonly bool $autoResolveTitles = false, diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e5646bd4..8fbec5ed 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -319,12 +319,12 @@ class ShortUrl extends AbstractEntity } $now = Chronos::now(); - $beforeValidSince = $this->validSince !== null && $this->validSince->gt($now); + $beforeValidSince = $this->validSince !== null && $this->validSince->greaterThan($now); if ($beforeValidSince) { return false; } - $afterValidUntil = $this->validUntil !== null && $this->validUntil->lt($now); + $afterValidUntil = $this->validUntil !== null && $this->validUntil->lessThan($now); if ($afterValidUntil) { return false; } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 9d21cb58..886a4d25 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -11,6 +11,9 @@ use function sprintf; class ShortUrlStringifier implements ShortUrlStringifierInterface { + /** + * @param array{schema?: string, hostname?: string} $domainConfig + */ public function __construct(private readonly array $domainConfig, private readonly string $basePath = '') { } diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugValidator.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugValidator.php new file mode 100644 index 00000000..24afc72e --- /dev/null +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugValidator.php @@ -0,0 +1,63 @@ + 'Provided value is not a string.', + self::CONTAINS_URL_CHARACTERS => 'URL-reserved characters cannot be used in a custom slug.', + ]; + + private UrlShortenerOptions $options; + + private function __construct() + { + parent::__construct(); + } + + public static function forUrlShortenerOptions(UrlShortenerOptions $options): self + { + $instance = new self(); + $instance->options = $options; + + return $instance; + } + + public function isValid(mixed $value): bool + { + if ($value === null) { + return true; + } + + if (! is_string($value)) { + $this->error(self::NOT_STRING); + return false; + } + + // URL reserved characters: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 + $reservedChars = "!*'();:@&=+$,?%#[]"; + if (! $this->options->multiSegmentSlugsEnabled) { + // Slashes should be allowed for multi-segment slugs + $reservedChars .= '/'; + } + + if (strpbrk($value, $reservedChars) !== false) { + $this->error(self::CONTAINS_URL_CHARACTERS); + return false; + } + + return true; + } +} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index af7e8986..23ac8a2f 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -81,13 +81,15 @@ class ShortUrlInputFilter extends InputFilter $this->add($validUntil); // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value - // is by using the deprecated setContinueIfEmpty + // is with setContinueIfEmpty $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); - $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ - Validator\NotEmpty::STRING, - Validator\NotEmpty::SPACE, - ])); + $customSlug->getValidatorChain() + ->attach(new Validator\NotEmpty([ + Validator\NotEmpty::STRING, + Validator\NotEmpty::SPACE, + ])) + ->attach(CustomSlugValidator::forUrlShortenerOptions($options)); $this->add($customSlug); $this->add($this->createNumericInput(self::MAX_VISITS, false)); diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 68e5df4b..278dbe8b 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -56,10 +56,11 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito Role::AUTHORED_SHORT_URLS => $qb->andWhere( $qb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), ), + default => $qb, }); - // For admins and when no API key is present, we'll return tags which are not linked to any short URL - $joiningMethod = ApiKey::isAdmin($apiKey) ? 'leftJoin' : 'join'; + // For non-restricted API keys, we'll return tags which are not linked to any short URL + $joiningMethod = ! ApiKey::isShortUrlRestricted($apiKey) ? 'leftJoin' : 'join'; $tagsSubQb = $conn->createQueryBuilder(); $tagsSubQb ->select('t.id AS tag_id', 't.name AS tag', 'COUNT(DISTINCT s.id) AS short_urls_count') diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index ea9a4e8b..36fd0514 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -59,7 +59,7 @@ class TagService implements TagServiceInterface */ public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void { - if (! ApiKey::isAdmin($apiKey)) { + if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forDeletion(); } @@ -75,7 +75,7 @@ class TagService implements TagServiceInterface */ public function renameTag(TagRenaming $renaming, ?ApiKey $apiKey = null): Tag { - if (! ApiKey::isAdmin($apiKey)) { + if (ApiKey::isShortUrlRestricted($apiKey)) { throw ForbiddenTagOperationException::forRenaming(); } diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 86b56f5e..255a55f4 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -188,6 +188,16 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->date; } + public function userAgent(): string + { + return $this->userAgent; + } + + public function referer(): string + { + return $this->referer; + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 4e6e4daf..e871d125 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -9,11 +9,15 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { - public function __construct(private readonly VisitRepositoryInterface $repo, private readonly VisitsParams $params) - { + public function __construct( + private readonly VisitRepositoryInterface $repo, + private readonly VisitsParams $params, + private readonly ?ApiKey $apiKey, + ) { } protected function doCount(): int @@ -21,6 +25,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte return $this->repo->countOrphanVisits(new VisitsCountFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, + apiKey: $this->apiKey, )); } @@ -29,6 +34,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte return $this->repo->findOrphanVisits(new VisitsListFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, + apiKey: $this->apiKey, limit: $length, offset: $offset, )); diff --git a/module/Core/src/Visit/Repository/VisitRepository.php b/module/Core/src/Visit/Repository/VisitRepository.php index 7021e70b..dc54057c 100644 --- a/module/Core/src/Visit/Repository/VisitRepository.php +++ b/module/Core/src/Visit/Repository/VisitRepository.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Spec\CountOfNonOrphanVisits; use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits; +use Shlinkio\Shlink\Rest\ApiKey\Role; use const PHP_INT_MAX; @@ -139,6 +140,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function findOrphanVisits(VisitsListFiltering $filtering): array { + if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { + return []; + } + $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNull('v.shortUrl')); return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); @@ -146,6 +151,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function countOrphanVisits(VisitsCountFiltering $filtering): int { + if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { + return 0; + } + return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering)); } diff --git a/module/Core/src/Visit/VisitsDeleter.php b/module/Core/src/Visit/VisitsDeleter.php index 5a8adca5..2b925e17 100644 --- a/module/Core/src/Visit/VisitsDeleter.php +++ b/module/Core/src/Visit/VisitsDeleter.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Model\BulkDeleteResult; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsDeleter implements VisitsDeleterInterface @@ -16,7 +17,7 @@ class VisitsDeleter implements VisitsDeleterInterface public function deleteOrphanVisits(?ApiKey $apiKey = null): BulkDeleteResult { - // TODO Check API key has permissions for orphan visits - return new BulkDeleteResult($this->repository->deleteOrphanVisits()); + $affectedItems = $apiKey?->hasRole(Role::NO_ORPHAN_VISITS) ? 0 : $this->repository->deleteOrphanVisits(); + return new BulkDeleteResult($affectedItems); } } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 25f44921..bdd2fd3b 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -43,11 +43,13 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface return new VisitsStats( nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), - orphanVisitsTotal: $visitsRepo->countOrphanVisits(new VisitsCountFiltering()), + orphanVisitsTotal: $visitsRepo->countOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), nonOrphanVisitsNonBots: $visitsRepo->countNonOrphanVisits( new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), - orphanVisitsNonBots: $visitsRepo->countOrphanVisits(new VisitsCountFiltering(excludeBots: true)), + orphanVisitsNonBots: $visitsRepo->countOrphanVisits( + new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), + ), ); } @@ -114,12 +116,12 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params): Paginator + public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); - return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params), $params); + return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params); } public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index af6cb77c..71173553 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -43,7 +43,7 @@ interface VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params): Paginator; + public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @return Visit[]|Paginator diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index dd5fff91..9e4b88df 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -75,6 +75,6 @@ class VisitsTracker implements VisitsTrackerInterface $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(UrlVisited::withOriginalIpAddress($visit->getId(), $visitor->remoteAddress)); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); } } diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index 6eb2fe4e..cca71a14 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -262,6 +262,9 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); + $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forNoOrphanVisits())); + $this->getEntityManager()->persist($noOrphanVisitsApiKey); + $apiKey1 = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls())); $this->getEntityManager()->persist($apiKey1); $shortUrl = ShortUrl::create( @@ -305,6 +308,7 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(4, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey1))); self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey2))); self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($domainApiKey))); + self::assertEquals(0, $this->repo->countOrphanVisits(VisitsCountFiltering::withApiKey($noOrphanVisitsApiKey))); self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-05')->startOfDay(), )))); @@ -326,6 +330,9 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist($shortUrl); $this->createVisitsForShortUrl($shortUrl, 7); + $noOrphanVisitsApiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forNoOrphanVisits())); + $this->getEntityManager()->persist($noOrphanVisitsApiKey); + $botsCount = 3; for ($i = 0; $i < 6; $i++) { $this->getEntityManager()->persist($this->setDateOnVisit( @@ -346,6 +353,7 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); + self::assertCount(0, $this->repo->findOrphanVisits(new VisitsListFiltering(apiKey: $noOrphanVisitsApiKey))); self::assertCount(18, $this->repo->findOrphanVisits(new VisitsListFiltering())); self::assertCount(15, $this->repo->findOrphanVisits(new VisitsListFiltering(null, true))); self::assertCount(5, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 5))); diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php new file mode 100644 index 00000000..00f78fe4 --- /dev/null +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -0,0 +1,180 @@ +checker()->shouldRegisterListener(event: '', listener: $listener, isAsync: false)); + } + + public static function provideListeners(): iterable + { + return [ + [NotifyVisitToRabbitMq::class], + [NotifyNewShortUrlToRabbitMq::class], + [NotifyVisitToRedis::class], + [NotifyNewShortUrlToRedis::class], + [NotifyVisitToMercure::class], + [NotifyNewShortUrlToMercure::class], + [SendVisitToMatomo::class], + [NotifyVisitToWebHooks::class], + [UpdateGeoLiteDb::class], + ]; + } + + /** + * @param array $expectedResult + */ + #[Test, DataProvider('provideConfiguredCheckers')] + public function appropriateListenersAreEnabledBasedOnConfig( + EnabledListenerChecker $checker, + array $expectedResult, + ): void { + foreach ($expectedResult as $listener => $shouldBeRegistered) { + self::assertEquals($shouldBeRegistered, $checker->shouldRegisterListener('', $listener, true)); + } + } + + public static function provideConfiguredCheckers(): iterable + { + yield 'RabbitMQ' => [self::checker(rabbitMqEnabled: true), [ + NotifyVisitToRabbitMq::class => true, + NotifyNewShortUrlToRabbitMq::class => true, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'Redis Pub/Sub' => [self::checker(redisPubSubEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => true, + NotifyNewShortUrlToRedis::class => true, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'Mercure' => [self::checker(mercureEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => true, + NotifyNewShortUrlToMercure::class => true, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'Webhooks' => [self::checker(webhooksEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => true, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'GeoLite' => [self::checker(geoLiteEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => true, + 'unknown' => false, + ]]; + yield 'Matomo' => [self::checker(matomoEnabled: true), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + SendVisitToMatomo::class => true, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'All disabled' => [self::checker(), [ + NotifyVisitToRabbitMq::class => false, + NotifyNewShortUrlToRabbitMq::class => false, + NotifyVisitToRedis::class => false, + NotifyNewShortUrlToRedis::class => false, + NotifyVisitToMercure::class => false, + NotifyNewShortUrlToMercure::class => false, + NotifyVisitToWebHooks::class => false, + UpdateGeoLiteDb::class => false, + 'unknown' => false, + ]]; + yield 'All enabled' => [self::checker( + rabbitMqEnabled: true, + redisPubSubEnabled: true, + mercureEnabled: true, + webhooksEnabled: true, + geoLiteEnabled: true, + matomoEnabled: true, + ), [ + NotifyVisitToRabbitMq::class => true, + NotifyNewShortUrlToRabbitMq::class => true, + NotifyVisitToRedis::class => true, + NotifyNewShortUrlToRedis::class => true, + NotifyVisitToMercure::class => true, + NotifyNewShortUrlToMercure::class => true, + SendVisitToMatomo::class => true, + NotifyVisitToWebHooks::class => true, + UpdateGeoLiteDb::class => true, + 'unknown' => false, + ]]; + } + + private static function checker( + bool $rabbitMqEnabled = false, + bool $redisPubSubEnabled = false, + bool $mercureEnabled = false, + bool $webhooksEnabled = false, + bool $geoLiteEnabled = false, + bool $matomoEnabled = false, + ): EnabledListenerChecker { + return new EnabledListenerChecker( + new RabbitMqOptions(enabled: $rabbitMqEnabled), + $redisPubSubEnabled, + new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null), + new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]), + new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null), + new MatomoOptions(enabled: $matomoEnabled), + ); + } +} diff --git a/module/Core/test/EventDispatcher/LocateVisitTest.php b/module/Core/test/EventDispatcher/LocateVisitTest.php index b6f21495..ddadde84 100644 --- a/module/Core/test/EventDispatcher/LocateVisitTest.php +++ b/module/Core/test/EventDispatcher/LocateVisitTest.php @@ -159,7 +159,7 @@ class LocateVisitTest extends TestCase { $ipAddr = $originalIpAddress ?? $visit->getRemoteAddr(); $location = new Location('', '', '', '', 0.0, 0.0, ''); - $event = UrlVisited::withOriginalIpAddress('123', $originalIpAddress); + $event = new UrlVisited('123', $originalIpAddress); $this->em->expects($this->once())->method('find')->with(Visit::class, '123')->willReturn($visit); $this->em->expects($this->once())->method('flush'); @@ -168,7 +168,9 @@ class LocateVisitTest extends TestCase $location, ); - $this->eventDispatcher->expects($this->once())->method('dispatch')->with(new VisitLocated('123')); + $this->eventDispatcher->expects($this->once())->method('dispatch')->with( + new VisitLocated('123', $originalIpAddress), + ); $this->logger->expects($this->never())->method('warning'); ($this->locateVisit)($event); diff --git a/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php new file mode 100644 index 00000000..94c66623 --- /dev/null +++ b/module/Core/test/EventDispatcher/Matomo/SendVisitToMatomoTest.php @@ -0,0 +1,188 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->trackerBuilder = $this->createMock(MatomoTrackerBuilderInterface::class); + } + + #[Test] + public function visitIsNotSentWhenMatomoIsDisabled(): void + { + $this->em->expects($this->never())->method('find'); + $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener(enabled: false))(new VisitLocated('123')); + } + + #[Test] + public function visitIsNotSentWhenItDoesNotExist(): void + { + $this->em->expects($this->once())->method('find')->willReturn(null); + $this->trackerBuilder->expects($this->never())->method('buildMatomoTracker'); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->once())->method('warning')->with( + 'Tried to send visit with id "{visitId}" to matomo, but it does not exist.', + ['visitId' => '123'], + ); + + ($this->listener())(new VisitLocated('123')); + } + + #[Test, DataProvider('provideTrackerMethods')] + public function visitIsSentWhenItExists(Visit $visit, ?string $originalIpAddress, array $invokedMethods): void + { + $visitId = '123'; + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView')->with(''); + + if ($visit->isOrphan()) { + $tracker->expects($this->exactly(2))->method('setCustomTrackingParameter')->willReturnMap([ + ['type', $visit->type()->value, $tracker], + ['orphan', 'true', $tracker], + ]); + } else { + $tracker->expects($this->once())->method('setCustomTrackingParameter')->with( + 'type', + $visit->type()->value, + )->willReturn($tracker); + } + + foreach ($invokedMethods as $invokedMethod) { + $tracker->expects($this->once())->method($invokedMethod)->willReturn($tracker); + } + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener())(new VisitLocated($visitId, $originalIpAddress)); + } + + public static function provideTrackerMethods(): iterable + { + yield 'unlocated orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), null, []]; + yield 'located regular visit' => [ + Visit::forValidShortUrl(ShortUrl::withLongUrl('https://shlink.io'), Visitor::emptyInstance()) + ->locate(VisitLocation::fromGeolocation(new Location( + countryCode: 'countryCode', + countryName: 'countryName', + regionName: 'regionName', + city: 'city', + latitude: 123, + longitude: 123, + timeZone: 'timeZone', + ))), + '1.2.3.4', + ['setCity', 'setCountry', 'setLatitude', 'setLongitude', 'setIp'], + ]; + yield 'fallback IP' => [Visit::forBasePath(new Visitor('', '', '1.2.3.4', '')), null, ['setIp']]; + } + + #[Test, DataProvider('provideUrlsToTrack')] + public function properUrlIsTracked(Visit $visit, string $expectedTrackedUrl): void + { + $visitId = '123'; + + $tracker = $this->createMock(MatomoTracker::class); + $tracker->expects($this->once())->method('setUrl')->with($expectedTrackedUrl)->willReturn($tracker); + $tracker->expects($this->once())->method('setUserAgent')->willReturn($tracker); + $tracker->expects($this->once())->method('setUrlReferrer')->willReturn($tracker); + $tracker->expects($this->any())->method('setCustomTrackingParameter')->willReturn($tracker); + $tracker->expects($this->once())->method('doTrackPageView'); + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn($visit); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willReturn($tracker); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + ($this->listener())(new VisitLocated($visitId)); + } + + public static function provideUrlsToTrack(): iterable + { + yield 'orphan visit without visited URL' => [Visit::forBasePath(Visitor::emptyInstance()), '']; + yield 'orphan visit with visited URL' => [ + Visit::forBasePath(new Visitor('', '', null, 'https://s.test/foo')), + 'https://s.test/foo', + ]; + yield 'non-orphan visit' => [ + Visit::forValidShortUrl(ShortUrl::create( + ShortUrlCreation::fromRawData([ + ShortUrlInputFilter::LONG_URL => 'https://shlink.io', + ShortUrlInputFilter::CUSTOM_SLUG => 'bar', + ]), + ), Visitor::emptyInstance()), + 'http://s2.test/bar', + ]; + } + + #[Test] + public function logsErrorWhenTrackingFails(): void + { + $visitId = '123'; + $e = new Exception('Error!'); + + $this->em->expects($this->once())->method('find')->with(Visit::class, $visitId)->willReturn( + $this->createMock(Visit::class), + ); + $this->trackerBuilder->expects($this->once())->method('buildMatomoTracker')->willThrowException($e); + $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->once())->method('error')->with( + 'An error occurred while trying to send visit to Matomo. {e}', + ['e' => $e], + ); + + ($this->listener())(new VisitLocated($visitId)); + } + + private function listener(bool $enabled = true): SendVisitToMatomo + { + return new SendVisitToMatomo( + $this->em, + $this->logger, + new ShortUrlStringifier(['hostname' => 's2.test']), + new MatomoOptions(enabled: $enabled), + $this->trackerBuilder, + ); + } +} diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index ff8eebc6..bf2896e2 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -307,9 +307,9 @@ class ImportedLinksProcessorTest extends TestCase yield 'existing orphan visit' => [true, [ new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(3), '', '', null), new ImportedShlinkOrphanVisit('', '', Chronos::now()->subDays(2), '', '', null), - new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), - new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), - new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDay(), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDays(1), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDays(1), '', '', null), + new ImportedShlinkOrphanVisit('', '', Chronos::now()->addDays(1), '', '', null), ], Visit::forBasePath(Visitor::botInstance()), 3]; } diff --git a/module/Core/test/Matomo/MatomoTrackerBuilderTest.php b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php new file mode 100644 index 00000000..5a4e6ab0 --- /dev/null +++ b/module/Core/test/Matomo/MatomoTrackerBuilderTest.php @@ -0,0 +1,51 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Cannot create MatomoTracker. Either site ID, base URL or api token are not defined', + ); + $this->builder($options)->buildMatomoTracker(); + } + + public static function provideInvalidOptions(): iterable + { + yield [new MatomoOptions()]; + yield [new MatomoOptions(baseUrl: 'base_url')]; + yield [new MatomoOptions(apiToken: 'api_token')]; + yield [new MatomoOptions(siteId: 5)]; + yield [new MatomoOptions(baseUrl: 'base_url', apiToken: 'api_token')]; + yield [new MatomoOptions(baseUrl: 'base_url', siteId: 5)]; + yield [new MatomoOptions(siteId: 5, apiToken: 'api_token')]; + } + + #[Test] + public function trackerIsCreated(): void + { + $tracker = $this->builder()->buildMatomoTracker(); + + self::assertEquals('api_token', $tracker->token_auth); // @phpstan-ignore-line + self::assertEquals(5, $tracker->idSite); // @phpstan-ignore-line + self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestTimeout()); + self::assertEquals(MatomoTrackerBuilder::MATOMO_DEFAULT_TIMEOUT, $tracker->getRequestConnectTimeout()); + } + + private function builder(?MatomoOptions $options = null): MatomoTrackerBuilder + { + $options ??= new MatomoOptions(enabled: true, baseUrl: 'base_url', siteId: 5, apiToken: 'api_token'); + return new MatomoTrackerBuilder($options); + } +} diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 401a3a31..47d4648c 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -62,6 +62,10 @@ class ShortUrlCreationTest extends TestCase ShortUrlInputFilter::LONG_URL => 'https://foo', ShortUrlInputFilter::CUSTOM_SLUG => '', ]]; + yield [[ + ShortUrlInputFilter::LONG_URL => 'https://foo', + ShortUrlInputFilter::CUSTOM_SLUG => 'foo?some=param', + ]]; yield [[ ShortUrlInputFilter::LONG_URL => 'https://foo', ShortUrlInputFilter::CUSTOM_SLUG => ' ', diff --git a/module/Core/test/ShortUrl/Model/Validation/CustomSlugValidatorTest.php b/module/Core/test/ShortUrl/Model/Validation/CustomSlugValidatorTest.php new file mode 100644 index 00000000..290fe63d --- /dev/null +++ b/module/Core/test/ShortUrl/Model/Validation/CustomSlugValidatorTest.php @@ -0,0 +1,77 @@ +createValidator(); + self::assertTrue($validator->isValid(null)); + } + + #[Test, DataProvider('provideNonStringValues')] + public function nonStringValuesAreInvalid(mixed $value): void + { + $validator = $this->createValidator(); + + self::assertFalse($validator->isValid($value)); + self::assertEquals(['NOT_STRING' => 'Provided value is not a string.'], $validator->getMessages()); + } + + public static function provideNonStringValues(): iterable + { + yield [123]; + yield [new stdClass()]; + yield [true]; + } + + #[Test] + public function slashesAreAllowedWhenMultiSegmentSlugsAreEnabled(): void + { + $slugWithSlashes = 'foo/bar/baz'; + + self::assertTrue($this->createValidator(multiSegmentSlugsEnabled: true)->isValid($slugWithSlashes)); + self::assertFalse($this->createValidator(multiSegmentSlugsEnabled: false)->isValid($slugWithSlashes)); + } + + #[Test, DataProvider('provideInvalidValues')] + public function valuesWithReservedCharsAreInvalid(string $value): void + { + $validator = $this->createValidator(); + + self::assertFalse($validator->isValid($value)); + self::assertEquals( + ['CONTAINS_URL_CHARACTERS' => 'URL-reserved characters cannot be used in a custom slug.'], + $validator->getMessages(), + ); + } + + public static function provideInvalidValues(): iterable + { + yield ['foo?bar=baz']; + yield ['some-thing#foo']; + yield ['call()']; + yield ['array[]']; + yield ['email@example.com']; + yield ['wildcard*']; + yield ['$500']; + } + + public function createValidator(bool $multiSegmentSlugsEnabled = false): CustomSlugValidator + { + return CustomSlugValidator::forUrlShortenerOptions( + new UrlShortenerOptions(multiSegmentSlugsEnabled: $multiSegmentSlugsEnabled), + ); + } +} diff --git a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php index 37a3eb36..a469ed60 100644 --- a/module/Core/test/ShortUrl/ShortUrlListServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlListServiceTest.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl; -use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -14,14 +14,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlListRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListService; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; use function count; class ShortUrlListServiceTest extends TestCase { - use ApiKeyHelpersTrait; - private ShortUrlListService $service; private MockObject & ShortUrlListRepositoryInterface $repo; @@ -31,7 +29,7 @@ class ShortUrlListServiceTest extends TestCase $this->service = new ShortUrlListService($this->repo, new UrlShortenerOptions()); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function listedUrlsAreReturnedFromEntityManager(?ApiKey $apiKey): void { $list = [ diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index f2b89586..4057691b 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,15 +23,13 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; use function Functional\map; use function range; class ShortUrlResolverTest extends TestCase { - use ApiKeyHelpersTrait; - private ShortUrlResolver $urlResolver; private MockObject & EntityManagerInterface $em; private MockObject & ShortUrlRepositoryInterface $repo; @@ -42,7 +41,7 @@ class ShortUrlResolverTest extends TestCase $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function shortCodeIsProperlyParsed(?ApiKey $apiKey): void { $shortUrl = ShortUrl::withLongUrl('https://expected_url'); @@ -59,7 +58,7 @@ class ShortUrlResolverTest extends TestCase self::assertSame($shortUrl, $result); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function exceptionIsThrownIfShortcodeIsNotFound(?ApiKey $apiKey): void { $shortCode = 'abc123'; @@ -122,15 +121,15 @@ class ShortUrlResolverTest extends TestCase return $shortUrl; })()]; yield 'future validSince' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validSince' => $now->addMonth()->toAtomString(), 'longUrl' => 'https://longUrl'], + ['validSince' => $now->addMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl'], ))]; yield 'past validUntil' => [ShortUrl::create(ShortUrlCreation::fromRawData( - ['validUntil' => $now->subMonth()->toAtomString(), 'longUrl' => 'https://longUrl'], + ['validUntil' => $now->subMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl'], ))]; yield 'mixed' => [(function () use ($now) { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'maxVisits' => 3, - 'validUntil' => $now->subMonth()->toAtomString(), + 'validUntil' => $now->subMonths(1)->toAtomString(), 'longUrl' => 'https://longUrl', ])); $shortUrl->setVisits(new ArrayCollection(map( diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 409c937f..67b10720 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -21,15 +21,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; use function array_fill_keys; use function Shlinkio\Shlink\Core\enumValues; class ShortUrlServiceTest extends TestCase { - use ApiKeyHelpersTrait; - private ShortUrlService $service; private MockObject & ShortUrlResolverInterface $urlResolver; private MockObject & ShortUrlTitleResolutionHelperInterface $titleResolutionHelper; diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 5e1b2665..f22a35f2 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Tag; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -22,12 +23,10 @@ use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; class TagServiceTest extends TestCase { - use ApiKeyHelpersTrait; - private TagService $service; private MockObject & EntityManagerInterface $em; private MockObject & TagRepository $repo; @@ -101,7 +100,7 @@ class TagServiceTest extends TestCase ]; } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function deleteTagsDelegatesOnRepository(?ApiKey $apiKey): void { $this->repo->expects($this->once())->method('deleteByName')->with(['foo', 'bar'])->willReturn(4); @@ -122,7 +121,7 @@ class TagServiceTest extends TestCase ); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function renameInvalidTagThrowsException(?ApiKey $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(null); @@ -152,7 +151,7 @@ class TagServiceTest extends TestCase yield 'different names names' => ['foo', 'bar', 0]; } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function renameTagToAnExistingNameThrowsException(?ApiKey $apiKey): void { $this->repo->expects($this->once())->method('findOneBy')->willReturn(new Tag('foo')); diff --git a/module/Core/test/Util/ApiKeyHelpersTrait.php b/module/Core/test/Util/ApiKeyDataProviders.php similarity index 72% rename from module/Core/test/Util/ApiKeyHelpersTrait.php rename to module/Core/test/Util/ApiKeyDataProviders.php index fc6af8af..72956e5b 100644 --- a/module/Core/test/Util/ApiKeyHelpersTrait.php +++ b/module/Core/test/Util/ApiKeyDataProviders.php @@ -6,9 +6,9 @@ namespace ShlinkioTest\Shlink\Core\Util; use Shlinkio\Shlink\Rest\Entity\ApiKey; -trait ApiKeyHelpersTrait +class ApiKeyDataProviders { - public static function provideAdminApiKeys(): iterable + public static function adminApiKeysProvider(): iterable { yield 'no API key' => [null]; yield 'admin API key' => [ApiKey::create()]; diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 6a367ed7..04e3f84c 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -15,18 +15,22 @@ use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class OrphanVisitsPaginatorAdapterTest extends TestCase { private OrphanVisitsPaginatorAdapter $adapter; private MockObject & VisitRepositoryInterface $repo; private VisitsParams $params; + private ApiKey $apiKey; protected function setUp(): void { $this->repo = $this->createMock(VisitRepositoryInterface::class); $this->params = VisitsParams::fromRawData([]); - $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params); + $this->apiKey = ApiKey::create(); + + $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); } #[Test] @@ -34,7 +38,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $this->repo->expects($this->once())->method('countOrphanVisits')->with( - new VisitsCountFiltering($this->params->dateRange), + new VisitsCountFiltering($this->params->dateRange, apiKey: $this->apiKey), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -51,9 +55,13 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; - $this->repo->expects($this->once())->method('findOrphanVisits')->with( - new VisitsListFiltering($this->params->dateRange, $this->params->excludeBots, null, $limit, $offset), - )->willReturn($list); + $this->repo->expects($this->once())->method('findOrphanVisits')->with(new VisitsListFiltering( + $this->params->dateRange, + $this->params->excludeBots, + $this->apiKey, + $limit, + $offset, + ))->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); diff --git a/module/Core/test/Visit/VisitsDeleterTest.php b/module/Core/test/Visit/VisitsDeleterTest.php index 155d0725..e47706a9 100644 --- a/module/Core/test/Visit/VisitsDeleterTest.php +++ b/module/Core/test/Visit/VisitsDeleterTest.php @@ -10,6 +10,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Visit\Repository\VisitDeleterRepositoryInterface; use Shlinkio\Shlink\Core\Visit\VisitsDeleter; +use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; +use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsDeleterTest extends TestCase { @@ -38,4 +41,16 @@ class VisitsDeleterTest extends TestCase yield '5000' => [5000]; yield '0' => [0]; } + + #[Test] + public function returnsNoDeletedVisitsForApiKeyWithNoPermission(): void + { + $this->repo->expects($this->never())->method('deleteOrphanVisits'); + + $result = $this->visitsDeleter->deleteOrphanVisits( + ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forNoOrphanVisits())), + ); + + self::assertEquals(0, $result->affectedItems); + } } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 18954a22..d43efc24 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use Laminas\Stdlib\ArrayUtils; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -30,7 +31,7 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use ShlinkioTest\Shlink\Core\Util\ApiKeyHelpersTrait; +use ShlinkioTest\Shlink\Core\Util\ApiKeyDataProviders; use function count; use function Functional\map; @@ -38,8 +39,6 @@ use function range; class VisitsStatsHelperTest extends TestCase { - use ApiKeyHelpersTrait; - private VisitsStatsHelper $helper; private MockObject & EntityManagerInterface $em; @@ -50,13 +49,14 @@ class VisitsStatsHelperTest extends TestCase } #[Test, DataProvider('provideCounts')] - public function returnsExpectedVisitsStats(int $expectedCount): void + public function returnsExpectedVisitsStats(int $expectedCount, ?ApiKey $apiKey): void { $repo = $this->createMock(VisitRepository::class); $callCount = 0; $repo->expects($this->exactly(2))->method('countNonOrphanVisits')->willReturnCallback( - function (VisitsCountFiltering $options) use ($expectedCount, &$callCount) { + function (VisitsCountFiltering $options) use ($expectedCount, $apiKey, &$callCount) { Assert::assertEquals($callCount !== 0, $options->excludeBots); + Assert::assertEquals($apiKey, $options->apiKey); $callCount++; return $expectedCount * 3; @@ -67,17 +67,20 @@ class VisitsStatsHelperTest extends TestCase )->willReturn($expectedCount); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); - $stats = $this->helper->getVisitsStats(); + $stats = $this->helper->getVisitsStats($apiKey); self::assertEquals(new VisitsStats($expectedCount * 3, $expectedCount), $stats); } public static function provideCounts(): iterable { - return map(range(0, 50, 5), fn (int $value) => [$value]); + return [ + ...map(range(0, 50, 5), fn (int $value) => [$value, null]), + ...map(range(0, 18, 3), fn (int $value) => [$value, ApiKey::create()]), + ]; } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function infoReturnsVisitsForCertainShortCode(?ApiKey $apiKey): void { $shortCode = '123ABC'; @@ -137,7 +140,7 @@ class VisitsStatsHelperTest extends TestCase $this->helper->visitsForTag($tag, new VisitsParams(), $apiKey); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function visitsForTagAreReturnedAsExpected(?ApiKey $apiKey): void { $tag = 'foo'; @@ -175,7 +178,7 @@ class VisitsStatsHelperTest extends TestCase $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void { $domain = 'foo.com'; @@ -203,7 +206,7 @@ class VisitsStatsHelperTest extends TestCase self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); } - #[Test, DataProvider('provideAdminApiKeys')] + #[Test, DataProviderExternal(ApiKeyDataProviders::class, 'adminApiKeysProvider')] public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void { $repo = $this->createMock(DomainRepository::class); diff --git a/module/Rest/config/access-logs.config.php b/module/Rest/config/access-logs.config.php new file mode 100644 index 00000000..1f0dd0e8 --- /dev/null +++ b/module/Rest/config/access-logs.config.php @@ -0,0 +1,27 @@ + [ + 'ignored_paths' => [ + Action\HealthAction::ROUTE_PATH, + ], + ], + + // This config needs to go in this file in order to override the value defined in shlink-common + ConfigAbstractFactory::class => [ + // Use MergeReplaceKey to overwrite what was defined in shlink-common, instead of merging it + AccessLogMiddleware::class => new MergeReplaceKey( + [AccessLogMiddleware::LOGGER_SERVICE_NAME, 'config.access_logs.ignored_paths'], + ), + ], + +]; diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php index 1e0b041b..7d0f3583 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKey.php @@ -44,7 +44,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createOneToMany('roles', ApiKeyRole::class) ->mappedBy('apiKey') - ->setIndexBy('roleName') + ->setIndexBy('role') ->cascadePersist() ->orphanRemoval() ->build(); diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php index 8df324a4..04d1cf9d 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php @@ -25,7 +25,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); (new FieldBuilder($builder, [ - 'fieldName' => 'roleName', + 'fieldName' => 'role', 'type' => Types::STRING, 'enumType' => Role::class, ]))->columnName('role_name') diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index f3bfea98..809bf4d1 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -17,7 +17,7 @@ class HealthAction extends AbstractRestAction private const STATUS_PASS = 'pass'; private const STATUS_FAIL = 'fail'; - protected const ROUTE_PATH = '/health'; + public const ROUTE_PATH = '/health'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; public function __construct(private EntityManagerInterface $em, private AppOptions $options) diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index af5292a2..c7adf3a1 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; +use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class OrphanVisitsAction extends AbstractRestAction { @@ -29,7 +30,8 @@ class OrphanVisitsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { $params = VisitsParams::fromRawData($request->getQueryParams()); - $visits = $this->visitsHelper->orphanVisits($params); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsHelper->orphanVisits($params, $apiKey); return new JsonResponse([ 'visits' => $this->serializePaginator($visits, $this->orphanVisitTransformer), diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index 403e6214..a35e904c 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -25,4 +25,9 @@ final class RoleDefinition ['domain_id' => $domain->getId(), 'authority' => $domain->authority], ); } + + public static function forNoOrphanVisits(): self + { + return new self(Role::NO_ORPHAN_VISITS, []); + } } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 5a4edb81..dd2d8ae7 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -12,16 +12,20 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined; use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; +use function sprintf; + enum Role: string { case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; + case NO_ORPHAN_VISITS = 'NO_ORPHAN_VISITS'; - public function toFriendlyName(): string + public function toFriendlyName(array $meta): string { return match ($this) { self::AUTHORED_SHORT_URLS => 'Author only', - self::DOMAIN_SPECIFIC => 'Domain only', + self::DOMAIN_SPECIFIC => sprintf('Domain only: %s', Role::domainAuthorityFromMeta($meta)), + self::NO_ORPHAN_VISITS => 'No orphan visits', }; } @@ -30,6 +34,7 @@ enum Role: string return match ($this) { self::AUTHORED_SHORT_URLS => 'author-only', self::DOMAIN_SPECIFIC => 'domain-only', + self::NO_ORPHAN_VISITS => 'no-orphan-visits', }; } @@ -38,6 +43,7 @@ enum Role: string return match ($role->role()) { self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context), self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context), + default => Spec::andX(), }; } @@ -46,6 +52,7 @@ enum Role: string return match ($role->role()) { self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())), self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))), + default => Spec::andX(), }; } diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 9a8f8056..122829ed 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -18,7 +18,7 @@ class WithApiKeySpecsEnsuringJoin extends BaseSpecification protected function getSpec(): Specification { - return $this->apiKey === null || ApiKey::isAdmin($this->apiKey) ? Spec::andX() : Spec::andX( + return $this->apiKey === null || ! ApiKey::isShortUrlRestricted($this->apiKey) ? Spec::andX() : Spec::andX( Spec::join($this->fieldToJoin, 's'), $this->apiKey->spec($this->fieldToJoin), ); diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 07d05a03..dae30de0 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -65,7 +65,7 @@ class ApiKey extends AbstractEntity public function isExpired(): bool { - return $this->expirationDate !== null && $this->expirationDate->lt(Chronos::now()); + return $this->expirationDate !== null && $this->expirationDate->lessThan(Chronos::now()); } public function name(): ?string @@ -122,6 +122,21 @@ class ApiKey extends AbstractEntity return $apiKey === null || $apiKey->roles->isEmpty(); } + /** + * Tells if provided API key has any of the roles restricting at the short URL level + */ + public static function isShortUrlRestricted(?ApiKey $apiKey): bool + { + if ($apiKey === null) { + return false; + } + + return ( + $apiKey->roles->containsKey(Role::AUTHORED_SHORT_URLS->value) + || $apiKey->roles->containsKey(Role::DOMAIN_SPECIFIC->value) + ); + } + public function hasRole(Role $role): bool { return $this->roles->containsKey($role->value); diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 8491cfce..6fadb839 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -9,13 +9,24 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKeyRole extends AbstractEntity { - public function __construct(private Role $roleName, private array $meta, private ApiKey $apiKey) + public function __construct(public readonly Role $role, private array $meta, public readonly ApiKey $apiKey) { } + /** + * @deprecated Use property access directly + */ public function role(): Role { - return $this->roleName; + return $this->role; + } + + /** + * @deprecated Use property access directly + */ + public function apiKey(): ApiKey + { + return $this->apiKey; } public function meta(): array @@ -27,9 +38,4 @@ class ApiKeyRole extends AbstractEntity { $this->meta = $newMeta; } - - public function apiKey(): ApiKey - { - return $this->apiKey; - } } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 5b22e79a..78f738a3 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -115,7 +115,7 @@ class CreateShortUrlTest extends ApiTestCase public function createsShortUrlWithValidSince(): void { [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([ - 'validSince' => Chronos::now()->addDay()->toAtomString(), + 'validSince' => Chronos::now()->addDays(1)->toAtomString(), ]); self::assertEquals(self::STATUS_OK, $statusCode); @@ -129,7 +129,7 @@ class CreateShortUrlTest extends ApiTestCase public function createsShortUrlWithValidUntil(): void { [$statusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([ - 'validUntil' => Chronos::now()->subDay()->toAtomString(), + 'validUntil' => Chronos::now()->subDays(1)->toAtomString(), ]); self::assertEquals(self::STATUS_OK, $statusCode); diff --git a/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php b/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php index b7cf59b9..63a2f165 100644 --- a/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php +++ b/module/Rest/test-api/Action/DeleteOrphanVisitsTest.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class DeleteOrphanVisitsTest extends ApiTestCase { #[Test] - public function deletesVisitsForShortUrlWithoutAffectingTheRest(): void + public function deletesOrphanVisitsWithoutAffectingTheRest(): void { self::assertEquals(7, $this->getTotalVisits()); self::assertEquals(3, $this->getOrphanVisits()); @@ -24,6 +24,21 @@ class DeleteOrphanVisitsTest extends ApiTestCase self::assertEquals(0, $this->getOrphanVisits()); } + #[Test] + public function doesNotDeleteOrphanVisitsForRestrictedApiKey(): void + { + self::assertEquals(7, $this->getTotalVisits()); + self::assertEquals(3, $this->getOrphanVisits()); + + $resp = $this->callApiWithKey(self::METHOD_DELETE, '/visits/orphan', apiKey: 'no_orphans_api_key'); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(200, $resp->getStatusCode()); + self::assertEquals(0, $payload['deletedVisits']); + self::assertEquals(7, $this->getTotalVisits()); // This verifies that regular visits have not been affected + self::assertEquals(3, $this->getOrphanVisits()); // This verifies that all orphan visits still exist + } + private function getTotalVisits(): int { $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/non-orphan'); diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index e15910ac..7bd3dfea 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -5,24 +5,28 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Action; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class DeleteShortUrlTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function notFoundErrorIsReturnWhenDeletingInvalidUrl( string $shortCode, ?string $domain, string $expectedDetail, string $apiKey, ): void { - $resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); + $resp = $this->callApiWithKey( + self::METHOD_DELETE, + UrlBuilder::buildShortUrlPath($shortCode, $domain), + apiKey: $apiKey, + ); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 22833970..a55fb066 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -9,16 +9,16 @@ use GuzzleHttp\Psr7\Query; use GuzzleHttp\RequestOptions; use Laminas\Diactoros\Uri; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class EditShortUrlTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - #[Test, DataProvider('provideMeta')] public function metadataCanBeReset(array $meta): void { @@ -55,13 +55,13 @@ class EditShortUrlTest extends ApiTestCase { $now = Chronos::now(); - yield [['validSince' => $now->addMonth()->toAtomString()]]; - yield [['validUntil' => $now->subMonth()->toAtomString()]]; + yield [['validSince' => $now->addMonths(1)->toAtomString()]]; + yield [['validUntil' => $now->subMonths(1)->toAtomString()]]; yield [['maxVisits' => 20]]; - yield [['validUntil' => $now->addYear()->toAtomString(), 'maxVisits' => 100]]; + yield [['validUntil' => $now->addYears(1)->toAtomString(), 'maxVisits' => 100]]; yield [[ - 'validSince' => $now->subYear()->toAtomString(), - 'validUntil' => $now->addYear()->toAtomString(), + 'validSince' => $now->subYears(1)->toAtomString(), + 'validUntil' => $now->addYears(1)->toAtomString(), 'maxVisits' => 100, ]]; } @@ -99,14 +99,14 @@ class EditShortUrlTest extends ApiTestCase yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'INVALID_URL']; } - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToEditInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, string $expectedDetail, string $apiKey, ): void { - $url = $this->buildShortUrlPath($shortCode, $domain); + $url = UrlBuilder::buildShortUrlPath($shortCode, $domain); $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []], $apiKey); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Action/GlobalVisitsTest.php b/module/Rest/test-api/Action/GlobalVisitsTest.php index 50591a14..657f16a6 100644 --- a/module/Rest/test-api/Action/GlobalVisitsTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsTest.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class GlobalVisitsTest extends ApiTestCase { #[Test, DataProvider('provideApiKeys')] - public function returnsExpectedVisitsStats(string $apiKey, int $expectedVisits): void + public function returnsExpectedVisitsStats(string $apiKey, int $expectedVisits, int $expectedOrphanVisits): void { $resp = $this->callApiWithKey(self::METHOD_GET, '/visits', [], $apiKey); $payload = $this->getJsonResponsePayload($resp); @@ -20,13 +20,14 @@ class GlobalVisitsTest extends ApiTestCase self::assertArrayHasKey('visitsCount', $payload['visits']); self::assertArrayHasKey('orphanVisitsCount', $payload['visits']); self::assertEquals($expectedVisits, $payload['visits']['visitsCount']); - self::assertEquals(3, $payload['visits']['orphanVisitsCount']); + self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisitsCount']); } public static function provideApiKeys(): iterable { - yield 'admin API key' => ['valid_api_key', 7]; - yield 'domain API key' => ['domain_api_key', 0]; - yield 'author API key' => ['author_api_key', 5]; + yield 'admin API key' => ['valid_api_key', 7, 3]; + yield 'domain API key' => ['domain_api_key', 0, 3]; + yield 'author API key' => ['author_api_key', 5, 3]; + yield 'no orphans API key' => ['no_orphans_api_key', 7, 0]; } } diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 6c599a4f..bb6296f7 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -169,7 +169,6 @@ class ListShortUrlsTest extends ApiTestCase public static function provideFilteredLists(): iterable { - // FIXME Cannot use enums in constants in PHP 8.1. Change this once support for PHP 8.1 is dropped $withDeviceLongUrls = static fn (array $shortUrl, ?array $longUrls = null) => [ ...$shortUrl, 'deviceLongUrls' => $longUrls ?? [ diff --git a/module/Rest/test-api/Action/NonOrphanVisitsTest.php b/module/Rest/test-api/Action/NonOrphanVisitsTest.php index f4f7601c..0e69db54 100644 --- a/module/Rest/test-api/Action/NonOrphanVisitsTest.php +++ b/module/Rest/test-api/Action/NonOrphanVisitsTest.php @@ -30,6 +30,6 @@ class NonOrphanVisitsTest extends ApiTestCase yield 'last page' => [['page' => 3, 'itemsPerPage' => 3], 7, 1]; yield 'bots excluded' => [['excludeBots' => 'true'], 6, 6]; yield 'bots excluded and pagination' => [['excludeBots' => 'true', 'page' => 1, 'itemsPerPage' => 4], 6, 4]; - yield 'date filter' => [['startDate' => Chronos::now()->addDay()->toAtomString()], 0, 0]; + yield 'date filter' => [['startDate' => Chronos::now()->addDays(1)->toAtomString()], 0, 0]; } } diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index 2049d80d..2c8b2479 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -69,4 +69,16 @@ class OrphanVisitsTest extends ApiTestCase [self::REGULAR_NOT_FOUND], ]; } + + #[Test] + public function noVisitsAreReturnedForRestrictedApiKey(): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', apiKey: 'no_orphans_api_key'); + $payload = $this->getJsonResponsePayload($resp); + $visits = $payload['visits']['data'] ?? null; + + self::assertIsArray($visits); + self::assertEmpty($visits); + self::assertEquals(0, $payload['visits']['pagination']['totalItems'] ?? Paginator::ALL_ITEMS); + } } diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index 08bc6cb0..c10abc74 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -7,16 +7,16 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class ResolveShortUrlTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - #[Test, DataProvider('provideDisabledMeta')] public function shortUrlIsProperlyResolvedEvenWhenNotEnabled(array $disabledMeta): void { @@ -37,19 +37,23 @@ class ResolveShortUrlTest extends ApiTestCase { $now = Chronos::now(); - yield 'future validSince' => [['validSince' => $now->addMonth()->toAtomString()]]; - yield 'past validUntil' => [['validUntil' => $now->subMonth()->toAtomString()]]; + yield 'future validSince' => [['validSince' => $now->addMonths(1)->toAtomString()]]; + yield 'past validUntil' => [['validUntil' => $now->subMonths(1)->toAtomString()]]; yield 'maxVisits reached' => [['maxVisits' => 1]]; } - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToResolveInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, string $expectedDetail, string $apiKey, ): void { - $resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain), [], $apiKey); + $resp = $this->callApiWithKey( + self::METHOD_GET, + UrlBuilder::buildShortUrlPath($shortCode, $domain), + apiKey: $apiKey, + ); $payload = $this->getJsonResponsePayload($resp); self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 70659ed8..6a7e6a7e 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -7,18 +7,18 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\Psr7\Query; use Laminas\Diactoros\Uri; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait; +use ShlinkioApiTest\Shlink\Rest\Utils\ApiTestDataProviders; +use ShlinkioApiTest\Shlink\Rest\Utils\UrlBuilder; use function sprintf; class ShortUrlVisitsTest extends ApiTestCase { - use NotFoundUrlHelpersTrait; - - #[Test, DataProvider('provideInvalidUrls')] + #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError( string $shortCode, ?string $domain, @@ -27,9 +27,8 @@ class ShortUrlVisitsTest extends ApiTestCase ): void { $resp = $this->callApiWithKey( self::METHOD_GET, - $this->buildShortUrlPath($shortCode, $domain, '/visits'), - [], - $apiKey, + UrlBuilder::buildShortUrlPath($shortCode, $domain, '/visits'), + apiKey: $apiKey, ); $payload = $this->getJsonResponsePayload($resp); diff --git a/module/Rest/test-api/Fixtures/ApiKeyFixture.php b/module/Rest/test-api/Fixtures/ApiKeyFixture.php index bc33c678..949a80c3 100644 --- a/module/Rest/test-api/Fixtures/ApiKeyFixture.php +++ b/module/Rest/test-api/Fixtures/ApiKeyFixture.php @@ -23,21 +23,29 @@ class ApiKeyFixture extends AbstractFixture implements DependentFixtureInterface public function load(ObjectManager $manager): void { - $manager->persist($this->buildApiKey('valid_api_key', true)); - $manager->persist($this->buildApiKey('disabled_api_key', false)); - $manager->persist($this->buildApiKey('expired_api_key', true, Chronos::now()->subDay()->startOfDay())); + $manager->persist($this->buildApiKey('valid_api_key', enabled: true)); + $manager->persist($this->buildApiKey('disabled_api_key', enabled: false)); + $manager->persist($this->buildApiKey( + 'expired_api_key', + enabled: true, + expiresAt: Chronos::now()->subDays(1)->startOfDay(), + )); - $authorApiKey = $this->buildApiKey('author_api_key', true); + $authorApiKey = $this->buildApiKey('author_api_key', enabled: true); $authorApiKey->registerRole(RoleDefinition::forAuthoredShortUrls()); $manager->persist($authorApiKey); $this->addReference('author_api_key', $authorApiKey); /** @var Domain $exampleDomain */ $exampleDomain = $this->getReference('example_domain'); - $domainApiKey = $this->buildApiKey('domain_api_key', true); + $domainApiKey = $this->buildApiKey('domain_api_key', enabled: true); $domainApiKey->registerRole(RoleDefinition::forDomain($exampleDomain)); $manager->persist($domainApiKey); + $authorApiKey = $this->buildApiKey('no_orphans_api_key', enabled: true); + $authorApiKey->registerRole(RoleDefinition::forNoOrphanVisits()); + $manager->persist($authorApiKey); + $manager->flush(); } diff --git a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php b/module/Rest/test-api/Utils/ApiTestDataProviders.php similarity index 62% rename from module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php rename to module/Rest/test-api/Utils/ApiTestDataProviders.php index 9645f707..0189535b 100644 --- a/module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php +++ b/module/Rest/test-api/Utils/ApiTestDataProviders.php @@ -4,14 +4,9 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Rest\Utils; -use GuzzleHttp\Psr7\Query; -use Laminas\Diactoros\Uri; - -use function sprintf; - -trait NotFoundUrlHelpersTrait +class ApiTestDataProviders { - public static function provideInvalidUrls(): iterable + public static function invalidUrlsProvider(): iterable { yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"', 'valid_api_key']; yield 'invalid shortcode without domain' => [ @@ -20,7 +15,7 @@ trait NotFoundUrlHelpersTrait 'No URL found with short code "abc123" for domain "example.com"', 'valid_api_key', ]; - yield 'invalid shortcode + domain' => [ + yield 'invalid shortcode and custom domain' => [ 'custom-with-domain', 'example.com', 'No URL found with short code "custom-with-domain" for domain "example.com"', @@ -32,21 +27,11 @@ trait NotFoundUrlHelpersTrait 'No URL found with short code "ghi789"', 'author_api_key', ]; - yield 'valid shortcode + domain with invalid API key' => [ + yield 'valid shortcode and custom domain with invalid API key' => [ 'custom-with-domain', 'some-domain.com', 'No URL found with short code "custom-with-domain" for domain "some-domain.com"', 'domain_api_key', ]; } - - public function buildShortUrlPath(string $shortCode, ?string $domain, string $suffix = ''): string - { - $url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix)); - if ($domain !== null) { - $url = $url->withQuery(Query::build(['domain' => $domain])); - } - - return (string) $url; - } } diff --git a/module/Rest/test-api/Utils/UrlBuilder.php b/module/Rest/test-api/Utils/UrlBuilder.php new file mode 100644 index 00000000..6de96a81 --- /dev/null +++ b/module/Rest/test-api/Utils/UrlBuilder.php @@ -0,0 +1,23 @@ +withQuery(Query::build(['domain' => $domain])); + } + + return $url->__toString(); + } +} diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index f4a22caa..da660d0e 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\OrphanVisitsAction; +use Shlinkio\Shlink\Rest\Entity\ApiKey; use function count; @@ -48,7 +49,9 @@ class OrphanVisitsActionTest extends TestCase )->willReturn([]); /** @var JsonResponse $response */ - $response = $this->action->handle(ServerRequestFactory::fromGlobals()); + $response = $this->action->handle( + ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()), + ); $payload = $response->getPayload(); self::assertCount($visitsAmount, $payload['visits']['data']); diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index b572630b..bf02318a 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -86,14 +86,15 @@ class RoleTest extends TestCase } #[Test, DataProvider('provideRoleNames')] - public function getsExpectedRoleFriendlyName(Role $role, string $expectedFriendlyName): void + public function getsExpectedRoleFriendlyName(Role $role, array $meta, string $expectedFriendlyName): void { - self::assertEquals($expectedFriendlyName, $role->toFriendlyName()); + self::assertEquals($expectedFriendlyName, $role->toFriendlyName($meta)); } public static function provideRoleNames(): iterable { - yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, 'Author only']; - yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, 'Domain only']; + yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, [], 'Author only']; + yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, ['authority' => 's.test'], 'Domain only: s.test']; + yield Role::NO_ORPHAN_VISITS->value => [Role::NO_ORPHAN_VISITS, [], 'No orphan visits']; } } diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 72063a72..305654b3 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -24,10 +24,11 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - self::assertCount(4, $config); + self::assertCount(5, $config); self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('auth', $config); self::assertArrayHasKey('entity_manager', $config); + self::assertArrayHasKey('access_logs', $config); self::assertArrayHasKey(ConfigAbstractFactory::class, $config); } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index c96d4f5f..3740bbfe 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -83,7 +83,9 @@ class ApiKeyServiceTest extends TestCase { yield 'non-existent api key' => [null]; yield 'disabled api key' => [ApiKey::create()->disable()]; - yield 'expired api key' => [ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: Chronos::now()->subDay()))]; + yield 'expired api key' => [ + ApiKey::fromMeta(ApiKeyMeta::fromParams(expirationDate: Chronos::now()->subDays(1))), + ]; } #[Test]