diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6acf4e6..b1ce0d0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,10 @@ on: jobs: static-analysis: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0'] + php-version: ['8.1'] command: ['cs', 'stan', 'swagger:validate'] steps: - name: Checkout code @@ -23,16 +23,17 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.1 coverage: none - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - run: composer ${{ matrix.command }} tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] test-group: ['unit', 'api'] steps: - name: Checkout code @@ -45,13 +46,14 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.1 coverage: pcov ini-values: pcov.directory=module - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - run: composer test:${{ matrix.test-group }}:ci - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.0' }} + if: ${{ matrix.php-version == '8.1' }} with: name: coverage-${{ matrix.test-group }} path: | @@ -59,10 +61,10 @@ jobs: build/coverage-${{ matrix.test-group }}.cov db-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms'] env: LC_ALL: C @@ -80,10 +82,11 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1, pdo_sqlsrv-5.10.0 + extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1 coverage: pcov ini-values: pcov.directory=module - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - name: Create test database if: ${{ matrix.platform == 'ms' }} run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" @@ -91,7 +94,7 @@ jobs: run: composer test:db:${{ matrix.platform }} - name: Upload code coverage uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.0' && matrix.platform == 'sqlite:ci' }} + if: ${{ matrix.php-version == '8.1' && matrix.platform == 'sqlite:ci' }} with: name: coverage-db path: | @@ -102,10 +105,10 @@ jobs: needs: - tests - db-tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] test-group: ['unit', 'db', 'api'] steps: - name: Checkout code @@ -115,10 +118,11 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.1 coverage: pcov ini-values: pcov.directory=module - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - uses: actions/download-artifact@v2 with: path: build @@ -133,10 +137,10 @@ jobs: needs: - tests - db-tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0'] + php-version: ['8.1'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -152,8 +156,8 @@ jobs: - run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov - - run: wget https://phar.phpunit.de/phpcov-8.2.0.phar - - run: php phpcov-8.2.0.phar merge build --clover build/clover.xml + - run: wget https://phar.phpunit.de/phpcov-8.2.1.phar + - run: php phpcov-8.2.1.phar merge build --clover build/clover.xml - name: Publish coverage uses: codecov/codecov-action@v1 with: @@ -163,7 +167,7 @@ jobs: needs: - mutation-tests - upload-coverage - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: geekyeggo/delete-artifact@v1 with: @@ -173,7 +177,7 @@ jobs: coverage-api build-docker-image: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index a4f47026..fb24e60b 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index dc9e516a..4903fe52 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -7,10 +7,10 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] swoole: ['yes', 'no'] steps: - name: Checkout code @@ -20,7 +20,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.1 - if: ${{ matrix.swoole == 'yes' }} run: ./build.sh ${GITHUB_REF#refs/tags/v} - if: ${{ matrix.swoole == 'no' }} @@ -32,7 +32,7 @@ jobs: publish: needs: ['build'] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 @@ -50,11 +50,11 @@ jobs: delete-artifacts: needs: ['publish'] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: [ '8.0', '8.1' ] - swoole: [ 'yes', 'no' ] + php-version: ['8.1'] + swoole: ['yes', 'no'] steps: - uses: geekyeggo/delete-artifact@v1 with: diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index bdbcc952..83864389 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -7,10 +7,10 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0'] + php-version: ['8.1'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -23,7 +23,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.9.1 + extensions: openswoole-4.11.1 coverage: none - run: composer install --no-interaction --prefer-dist - run: composer swagger:inline diff --git a/CHANGELOG.md b/CHANGELOG.md index 374dde6a..71842087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,114 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [3.2.1] - 2022-08-08 ### Added * *Nothing* +### Changed +* [#1495](https://github.com/shlinkio/shlink/issues/1495) Centralized how routes are configured to support multi-segment slugs. +* [#1497](https://github.com/shlinkio/shlink/issues/1497) Updated to latest shlink dependencies with support for PHP 8.1 only. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1499](https://github.com/shlinkio/shlink/issues/1499) Fixed loading of config options as env vars, which was making all default configurations to be loaded unless env vars were explicitly provided. + + +## [3.2.0] - 2022-08-05 +### Added +* [#854](https://github.com/shlinkio/shlink/issues/854) Added support for multi-segment custom slugs. + + The feature is disabled by default, but you can optionally opt in. If you do, you will be able to create short URLs with multiple segments in the custom slug, like `https://example.com/foo/bar/baz`. + +* [#1280](https://github.com/shlinkio/shlink/issues/1280) Added missing visit-related commands. + + Now you can run `tag:visits`, `domain:visits`, `visit:orphan` or `visit:non-orphan` to get the corresponding list of visits from the command line. + +* [#962](https://github.com/shlinkio/shlink/issues/962) Added new real-time update for new short URLs. + + You can now subscribe to the `https://shlink.io/new-short-url` topic on any of the supported async updates technologies in order to get notified when a short URL is created. + +* [#1367](https://github.com/shlinkio/shlink/issues/1367) Added support to publish real-time updates in redis pub/sub. + + The publishing will happen in the same redis instance/cluster configured for caching. + +### Changed +* [#1452](https://github.com/shlinkio/shlink/issues/1452) Updated to monolog 3 +* [#1485](https://github.com/shlinkio/shlink/issues/1485) Changed payload published in RabbitMQ for all visits events, in order to conform with the Async API spec. + + Since this is a breaking change, also provided a new `RABBITMQ_LEGACY_VISITS_PUBLISHING=true` env var that can be provided in order to keep the old payload. + + This env var is considered deprecated and will be removed in Shlink 4, when the legacy format will no longer be supported. + +### Deprecated +* *Nothing* + +### Removed +* [#1280](https://github.com/shlinkio/shlink/issues/1280) Dropped support for PHP 8.0 + +### Fixed +* [#1471](https://github.com/shlinkio/shlink/issues/1471) Fixed error when running `visit:locate` command with any extra parameter (like `--retry`). + + +## [3.1.2] - 2022-06-04 +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1448](https://github.com/shlinkio/shlink/issues/1448) Fixed HTML entities not being properly parsed when auto-resolving page titles. +* [#1458](https://github.com/shlinkio/shlink/issues/1458) Fixed 500 error when filtering short URLs by ALL tags and search term. + + +## [3.1.1] - 2022-05-09 +### Added +* *Nothing* + +### Changed +* [#1444](https://github.com/shlinkio/shlink/issues/1444) Updated docker image to openswoole 4.11.1, in an attempt to fix error. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1439](https://github.com/shlinkio/shlink/issues/1439) Fixed crash when trying to auto-resolve titles for URLs which serve large binary files. + + +## [3.1.0] - 2022-04-23 +### Added +* [#1294](https://github.com/shlinkio/shlink/issues/1294) Allowed to provide a specific domain when importing URLs from YOURLS. +* [#1416](https://github.com/shlinkio/shlink/issues/1416) Added support to import URLs from Kutt.it. +* [#1418](https://github.com/shlinkio/shlink/issues/1418) Added support to customize the timezone used by Shlink, falling back to the default one set in PHP config. + + The timezone can be set via the `TIMEZONE` env var, or using the installer tool. + +* [#1309](https://github.com/shlinkio/shlink/issues/1309) Improved URL importing, ensuring individual errors do not make the whole process fail, and instead, failing URLs are skipped. +* [#1162](https://github.com/shlinkio/shlink/issues/1162) Added new endpoint to get visits by domain. + + The endpoint is `GET /domains/{domain}/visits`, and it has the same capabilities as any other visits endpoint, allowing pagination and filtering. + ### Changed * [#1359](https://github.com/shlinkio/shlink/issues/1359) Hidden database commands. +* [#1385](https://github.com/shlinkio/shlink/issues/1385) Prevented a big error message from being logged when using Shlink without mercure. +* [#1398](https://github.com/shlinkio/shlink/issues/1398) Increased required mutation score for unit tests to 85%. +* [#1419](https://github.com/shlinkio/shlink/issues/1419) Input dates are now parsed to Shlink's configured timezone or default timezone before using them for database queries. +* [#1428](https://github.com/shlinkio/shlink/issues/1428) Updated native dependencies in docker image and base image to PHP v8.1.5. ### Deprecated * [#1340](https://github.com/shlinkio/shlink/issues/1340) Deprecated webhooks. New events will only be added to other real-time updates approaches, and webhooks will be completely removed in Shlink 4.0.0. @@ -18,8 +120,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed +* [#1397](https://github.com/shlinkio/shlink/issues/1397) Fixed `db:create` command always reporting the schema exists if the `db:migrate` command has been run before by mistake. +* [#1402](https://github.com/shlinkio/shlink/issues/1402) Fixed the base path getting appended with the default domain by mistake, causing multiple side effects in several places. + + +## [3.0.3] - 2022-02-19 +### Added * *Nothing* +### Changed +* [#1382](https://github.com/shlinkio/shlink/issues/1382) Updated docker image to PHP 8.1.3. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#1377](https://github.com/shlinkio/shlink/issues/1377) Fixed installer always setting delete threshold with value 1. +* [#1379](https://github.com/shlinkio/shlink/issues/1379) Ensured API keys cannot be created with a domain-only role linked to default domain. + ## [3.0.2] - 2022-02-10 ### Added @@ -538,7 +659,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed -* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short RULs list. +* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short URLs list. * [#980](https://github.com/shlinkio/shlink/issues/980) Fixed value used for `Access-Control-Allow-Origin`, that could not work as expected when including an IP address. * [#947](https://github.com/shlinkio/shlink/issues/947) Fixed incorrect value returned in `Access-Control-Allow-Methods` header, which always contained all methods. @@ -1186,7 +1307,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Endpoints and commands which create short URLs support providing the `domain` now (via query param or CLI flag). If not provided, the short URLs will still be "attached" to the default domain. - Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-compaign` and `https://example.com/my-campaign`, under the same shlink instance. + Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance. When resolving a short URL to redirect end users, the following rules are applied: @@ -1436,7 +1557,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser. -* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddlware` to be always piped. Now the check is not even made, which simplifies everything. +* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddleware` to be always piped. Now the check is not even made, which simplifies everything. ## [1.15.0] - 2018-12-02 @@ -1501,7 +1622,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase. -* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monlog's `PsrLogMessageProcessor`. +* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monolog's `PsrLogMessageProcessor`. * [#225](https://github.com/shlinkio/shlink/issues/225) Performance and maintainability slightly improved by enforcing via code sniffer that all global namespace classes, functions and constants are explicitly imported. * [#196](https://github.com/shlinkio/shlink/issues/196) Reduced anemic model in entities, defining more expressive public APIs instead. * [#249](https://github.com/shlinkio/shlink/issues/249) Added [functional-php](https://github.com/lstrojny/functional-php) to ease collections handling. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb3e7c83..2024adca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,9 +46,7 @@ This is a simplified version of the project structure: ``` shlink ├── bin -│ ├── cli -│ ├── install -│ └── update +│ └── cli ├── config │ ├── autoload │ ├── params @@ -75,11 +73,11 @@ 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, while `install` and `update` are helper tools used to install and update shlink when not using the docker image. +* `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 subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project. +* `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. ## Project tests @@ -125,12 +123,6 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, * Run `./indocker composer ci` to run all previous commands together. This command is run during the project's continuous integration. * Run `./indocker composer ci:parallel` to do the same as in previous case, but parallelizing non-conflicting tasks as much as possible. -> Note: Due to some limitations in the tooling used by shlink, the testing databases need to exist beforehand, both for db and api tests (except sqlite). -> -> However, they just need to be created empty, with no tables. Also, once created, they are automatically reset before every new execution. -> -> The testing database is always called `shlink_test`. You can create it using the database client of your choice. [DBeaver](https://dbeaver.io/) is a good multi-platform desktop database client which supports all the engines supported by shlink. - ## 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 8ee2ffa9..2944db45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM php:8.1.1-alpine3.15 as base +FROM php:8.1.9-alpine3.16 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} -ENV OPENSWOOLE_VERSION 4.9.1 -ENV PDO_SQLSRV_VERSION 5.10.0 +ENV OPENSWOOLE_VERSION 4.11.1 +ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 ENV LC_ALL "C" diff --git a/README.md b/README.md index 6f4afd37..1fe3b89c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The idea is that you can just generate a container using the image and provide t First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 8.0 or 8.1 +* PHP 8.1 * 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. @@ -66,7 +66,9 @@ In order to run Shlink, you will need a built version of the project. There are After that, you will have a dist file inside the `build` directory, that you need to decompress in the location of your choice. - > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it. + > **Note** + > + > This is the process used when releasing new Shlink versions. After tagging the new version with git, the GitHub release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it. ### Configure diff --git a/UPGRADE.md b/UPGRADE.md index bce1bdde..6bef9dbc 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -76,7 +76,7 @@ These routes have been removed, but have a direct replacement: * `/qr/{shortCode}[/{size}]` -> `/{shortCode}/qr-code[/{size}]` * `PUT /rest/v{version}/short-urls/{shortCode}` -> `PATCH /rest/v{version}/short-urls/{shortCode}` -When using the old ones, a 404 status will me returned now. +When using the old ones, a 404 status will be returned now. ### Removed command and route aliases diff --git a/composer.json b/composer.json index 9f8326e1..815fc6d6 100644 --- a/composer.json +++ b/composer.json @@ -12,70 +12,65 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^2.3", - "doctrine/migrations": "^3.3", - "doctrine/orm": "^2.11", + "doctrine/migrations": "^3.5", + "doctrine/orm": "^2.12", "endroid/qr-code": "^4.4", "geoip2/geoip2": "^2.12", "guzzlehttp/guzzle": "^7.4", "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2.110", "laminas/laminas-config": "^3.7", - "laminas/laminas-config-aggregator": "^1.7", - "laminas/laminas-diactoros": "^2.8", - "laminas/laminas-inputfilter": "^2.13", - "laminas/laminas-servicemanager": "^3.10", - "laminas/laminas-stdlib": "^3.6", + "laminas/laminas-config-aggregator": "^1.8", + "laminas/laminas-diactoros": "^2.14", + "laminas/laminas-inputfilter": "^2.19", + "laminas/laminas-servicemanager": "^3.16", + "laminas/laminas-stdlib": "^3.11", "lcobucci/jwt": "^4.1", - "league/uri": "^6.4", + "league/uri": "^6.7", "lstrojny/functional-php": "^1.17", - "mezzio/mezzio": "^3.7", - "mezzio/mezzio-fastroute": "^3.3", - "mezzio/mezzio-problem-details": "^1.5", - "mezzio/mezzio-swoole": "^4.0", - "mlocati/ip-lib": "^1.17", - "monolog/monolog": "^2.3", - "nikolaposa/monolog-factory": "^3.1", - "ocramius/proxy-manager": "^2.11", - "pagerfanta/core": "^3.5", - "php-amqplib/php-amqplib": "^3.1", + "mezzio/mezzio": "^3.11", + "mezzio/mezzio-fastroute": "^3.5", + "mezzio/mezzio-problem-details": "^1.6", + "mezzio/mezzio-swoole": "^4.3", + "mlocati/ip-lib": "^1.18", + "ocramius/proxy-manager": "^2.14", + "pagerfanta/core": "^3.6", "php-middleware/request-id": "^4.1", - "predis/predis": "^1.1", "pugx/shortid-php": "^1.0", - "ramsey/uuid": "^4.2", - "shlinkio/shlink-common": "^4.4", - "shlinkio/shlink-config": "^1.6", - "shlinkio/shlink-event-dispatcher": "^2.3", - "shlinkio/shlink-importer": "^2.5", - "shlinkio/shlink-installer": "dev-develop#68f5de1 as 7.1", - "shlinkio/shlink-ip-geolocation": "^2.2", - "symfony/console": "^6.0", - "symfony/filesystem": "^6.0", - "symfony/lock": "^6.0", - "symfony/mercure": "^0.6", - "symfony/process": "^6.0", - "symfony/string": "^6.0" + "ramsey/uuid": "^4.3", + "shlinkio/shlink-common": "^5.0", + "shlinkio/shlink-config": "^2.0", + "shlinkio/shlink-event-dispatcher": "^2.5", + "shlinkio/shlink-importer": "^4.0", + "shlinkio/shlink-installer": "^8.1", + "shlinkio/shlink-ip-geolocation": "^3.0", + "symfony/console": "^6.1", + "symfony/filesystem": "^6.1", + "symfony/lock": "^6.1", + "symfony/process": "^6.1", + "symfony/string": "^6.1" }, "require-dev": { - "cebe/php-openapi": "^1.6", - "devster/ubench": "^2.1", + "cebe/php-openapi": "^1.7", "dms/phpunit-arraysubset-asserts": "^0.4.0", - "infection/infection": "^0.26", - "openswoole/ide-helper": "~4.9.1", + "devster/ubench": "^2.1", + "infection/infection": "^0.26.5", + "openswoole/ide-helper": "~4.11.1", "phpspec/prophecy-phpunit": "^2.0", - "phpstan/phpstan": "^1.2", - "phpstan/phpstan-doctrine": "^1.0", - "phpstan/phpstan-symfony": "^1.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-doctrine": "^1.3", + "phpstan/phpstan-symfony": "^1.2", "phpunit/php-code-coverage": "^9.2", "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", - "shlinkio/php-coding-standard": "~2.2.0", - "shlinkio/shlink-test-utils": "^3.0", - "symfony/var-dumper": "^6.0", + "shlinkio/php-coding-standard": "~2.3.0", + "shlinkio/shlink-test-utils": "^3.1.0", + "symfony/var-dumper": "^6.1", "veewee/composer-run-parallel": "^1.1" }, "autoload": { @@ -142,7 +137,7 @@ "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml", "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli", "infect:ci:base": "infection --threads=4 --log-verbosity=default --only-covered --only-covering-test-cases --skip-initial-tests", - "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", + "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=84", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json", "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=80 --configuration=infection-api.json", "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api", @@ -179,7 +174,7 @@ "test:db:mysql": "Runs database test suites on a MySQL database", "test:db:maria": "Runs database test suites on a MariaDB database", "test:db:postgres": "Runs database test suites on a PostgreSQL database", - "test:db:ms": "Runs database test suites on a Miscrosoft SQL Server database", + "test:db:ms": "Runs database test suites on a Microsoft SQL Server database", "test:api": "Runs API test suites", "test:api:ci": "Runs API test suites, and generates code coverage reports", "infect:ci": "Checks unit and db tests quality applying mutation testing with existing reports and logs", diff --git a/config/autoload/app_options.local.php.dist b/config/autoload/app_options.local.php.dist new file mode 100644 index 00000000..14633a61 --- /dev/null +++ b/config/autoload/app_options.local.php.dist @@ -0,0 +1,11 @@ + [ + 'version' => 'latest', + ], + +]; diff --git a/config/autoload/delete_short_urls.global.php b/config/autoload/delete_short_urls.global.php index 3d562f78..2d203ea1 100644 --- a/config/autoload/delete_short_urls.global.php +++ b/config/autoload/delete_short_urls.global.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink; use Shlinkio\Shlink\Core\Config\EnvVars; return (static function (): array { - $threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD()->loadFromEnv(); + $threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD->loadFromEnv(); return [ diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index d98d37dc..5a75ca6b 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars; use function Functional\contains; return (static function (): array { - $driver = EnvVars::DB_DRIVER()->loadFromEnv(); + $driver = EnvVars::DB_DRIVER->loadFromEnv(); $isMysqlCompatible = contains(['maria', 'mysql'], $driver); $resolveDriver = static fn () => match ($driver) { @@ -35,12 +35,12 @@ return (static function (): array { ], default => [ 'driver' => $resolveDriver(), - 'dbname' => EnvVars::DB_NAME()->loadFromEnv('shlink'), - 'user' => EnvVars::DB_USER()->loadFromEnv(), - 'password' => EnvVars::DB_PASSWORD()->loadFromEnv(), - 'host' => EnvVars::DB_HOST()->loadFromEnv(EnvVars::DB_UNIX_SOCKET()->loadFromEnv()), - 'port' => EnvVars::DB_PORT()->loadFromEnv($resolveDefaultPort()), - 'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET()->loadFromEnv() : null, + 'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'), + 'user' => EnvVars::DB_USER->loadFromEnv(), + 'password' => EnvVars::DB_PASSWORD->loadFromEnv(), + 'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()), + 'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()), + 'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null, 'charset' => $resolveCharset(), ], }; diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php index cf1f57fc..b31cfc6d 100644 --- a/config/autoload/geolite2.global.php +++ b/config/autoload/geolite2.global.php @@ -9,7 +9,7 @@ return [ 'geolite2' => [ 'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb', 'temp_dir' => __DIR__ . '/../../data', - 'license_key' => EnvVars::GEOLITE_LICENSE_KEY()->loadFromEnv(), + 'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(), ], ]; diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 81f9941a..2e120e35 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -27,10 +27,12 @@ return [ Option\Redirect\Regular404RedirectConfigOption::class, Option\Visit\VisitsThresholdConfigOption::class, Option\BasePathConfigOption::class, + Option\TimezoneConfigOption::class, Option\Worker\TaskWorkerNumConfigOption::class, Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, Option\Redis\RedisSentinelServiceConfigOption::class, + Option\Redis\RedisPubSubConfigOption::class, Option\UrlShortener\ShortCodeLengthOption::class, Option\Mercure\EnableMercureConfigOption::class, Option\Mercure\MercurePublicUrlConfigOption::class, @@ -41,6 +43,7 @@ return [ Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class, Option\UrlShortener\AutoResolveTitlesConfigOption::class, Option\UrlShortener\AppendExtraPathConfigOption::class, + Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class, @@ -63,13 +66,13 @@ return [ ], 'installation_commands' => [ - InstallationCommand::DB_CREATE_SCHEMA => [ + InstallationCommand::DB_CREATE_SCHEMA->value => [ 'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME, ], - InstallationCommand::DB_MIGRATE => [ + InstallationCommand::DB_MIGRATE->value => [ 'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME, ], - InstallationCommand::GEOLITE_DOWNLOAD_DB => [ + InstallationCommand::GEOLITE_DOWNLOAD_DB->value => [ 'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME, ], ], diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index bdbdb8e5..5e37e770 100644 --- a/config/autoload/locks.global.php +++ b/config/autoload/locks.global.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use Predis\ClientInterface as PredisClient; +use Shlinkio\Shlink\Common\Cache\RedisFactory; use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory; use Shlinkio\Shlink\Core\Config\EnvVars; use Symfony\Component\Lock; @@ -24,7 +24,7 @@ return [ LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class, ], 'aliases' => [ - 'lock_store' => EnvVars::REDIS_SERVERS()->existsInEnv() ? 'redis_lock_store' : 'local_lock_store', + 'lock_store' => EnvVars::REDIS_SERVERS->existsInEnv() ? 'redis_lock_store' : 'local_lock_store', 'redis_lock_store' => Lock\Store\RedisStore::class, 'local_lock_store' => Lock\Store\FlockStore::class, @@ -38,7 +38,7 @@ return [ ConfigAbstractFactory::class => [ Lock\Store\FlockStore::class => ['config.locks.locks_dir'], - Lock\Store\RedisStore::class => [PredisClient::class], + Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME], Lock\LockFactory::class => ['lock_store'], LOCAL_LOCK_FACTORY => ['local_lock_store'], ], diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index e6fcd43c..2da1eda3 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -4,72 +4,36 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Monolog\Formatter; -use Monolog\Handler; +use Monolog\Level; use Monolog\Logger; -use Monolog\Processor; -use MonologFactory\DiContainerLoggerFactory; use PhpMiddleware\RequestId; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Logger\LoggerFactory; +use Shlinkio\Shlink\Common\Logger\LoggerType; -use const PHP_EOL; - -$processors = [ - 'exception_with_new_line' => [ - 'name' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class, - ], - 'psr3' => [ - 'name' => Processor\PsrLogMessageProcessor::class, - ], - 'request_id' => RequestId\MonologProcessor::class, -]; -$formatter = [ - 'name' => Formatter\LineFormatter::class, - 'params' => [ - 'format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%' . PHP_EOL, - 'allow_inline_line_breaks' => true, - ], +$common = [ + 'level' => Level::Info->value, + 'processors' => [RequestId\MonologProcessor::class], + 'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%', ]; return [ 'logger' => [ 'Shlink' => [ - 'name' => 'Shlink', - 'handlers' => [ - 'shlink_handler' => [ - 'name' => Handler\RotatingFileHandler::class, - 'params' => [ - 'level' => Logger::INFO, - 'filename' => 'data/log/shlink_log.log', - 'max_files' => 30, - 'file_permission' => 0666, - ], - 'formatter' => $formatter, - ], - ], - 'processors' => $processors, + 'type' => LoggerType::FILE->value, + ...$common, ], 'Access' => [ - 'name' => 'Access', - 'handlers' => [ - 'access_handler' => [ - 'name' => Handler\StreamHandler::class, - 'params' => [ - 'level' => Logger::INFO, - 'stream' => 'php://stdout', - ], - 'formatter' => $formatter, - ], - ], - 'processors' => $processors, + 'type' => LoggerType::STREAM->value, + ...$common, ], ], 'dependencies' => [ 'factories' => [ - 'Logger_Shlink' => [DiContainerLoggerFactory::class, 'Shlink'], - 'Logger_Access' => [DiContainerLoggerFactory::class, 'Access'], + 'Logger_Shlink' => [LoggerFactory::class, 'Shlink'], + 'Logger_Access' => [LoggerFactory::class, 'Access'], ], 'aliases' => [ 'logger' => 'Logger_Shlink', diff --git a/config/autoload/logger.local.php.dist b/config/autoload/logger.local.php.dist index 1da0384b..7288ed06 100644 --- a/config/autoload/logger.local.php.dist +++ b/config/autoload/logger.local.php.dist @@ -2,33 +2,18 @@ declare(strict_types=1); -use Monolog\Handler\StreamHandler; -use Monolog\Logger; +use Monolog\Level; +use Shlinkio\Shlink\Common\Logger\LoggerType; $isSwoole = extension_loaded('openswoole'); -// For swoole, send logs to standard output -$handler = $isSwoole - ? [ - 'name' => StreamHandler::class, - 'params' => [ - 'level' => Logger::DEBUG, - 'stream' => 'php://stdout', - ], - ] - : [ - 'params' => [ - 'level' => Logger::DEBUG, - ], - ]; - return [ 'logger' => [ 'Shlink' => [ - 'handlers' => [ - 'shlink_handler' => $handler, - ], + // For swoole, send logs as stream + 'type' => $isSwoole ? LoggerType::STREAM->value : LoggerType::FILE->value, + 'level' => Level::Debug->value, ], ], diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index ba261369..67143919 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -9,14 +9,14 @@ use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\HubInterface; return (static function (): array { - $publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL()->loadFromEnv(); + $publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv(); return [ 'mercure' => [ 'public_hub_url' => $publicUrl, - 'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL()->loadFromEnv($publicUrl), - 'jwt_secret' => EnvVars::MERCURE_JWT_SECRET()->loadFromEnv(), + 'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv($publicUrl), + 'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(), 'jwt_issuer' => 'Shlink', ], diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index d72198af..dc4f5f9e 100644 --- a/config/autoload/qr-codes.global.php +++ b/config/autoload/qr-codes.global.php @@ -13,13 +13,13 @@ use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE; return [ 'qr_codes' => [ - 'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE()->loadFromEnv(DEFAULT_QR_CODE_SIZE), - 'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN()->loadFromEnv(DEFAULT_QR_CODE_MARGIN), - 'format' => EnvVars::DEFAULT_QR_CODE_FORMAT()->loadFromEnv(DEFAULT_QR_CODE_FORMAT), - 'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION()->loadFromEnv( + 'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(DEFAULT_QR_CODE_SIZE), + 'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(DEFAULT_QR_CODE_MARGIN), + 'format' => EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(DEFAULT_QR_CODE_FORMAT), + 'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv( DEFAULT_QR_CODE_ERROR_CORRECTION, ), - 'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE()->loadFromEnv( + 'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv( DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, ), ], diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index faa5f569..ea003809 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -2,46 +2,20 @@ declare(strict_types=1); -use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use Laminas\ServiceManager\Proxy\LazyServiceFactory; -use PhpAmqpLib\Connection\AMQPStreamConnection; use Shlinkio\Shlink\Core\Config\EnvVars; return [ 'rabbitmq' => [ - 'enabled' => (bool) EnvVars::RABBITMQ_ENABLED()->loadFromEnv(false), - 'host' => EnvVars::RABBITMQ_HOST()->loadFromEnv(), - 'port' => (int) EnvVars::RABBITMQ_PORT()->loadFromEnv('5672'), - 'user' => EnvVars::RABBITMQ_USER()->loadFromEnv(), - 'password' => EnvVars::RABBITMQ_PASSWORD()->loadFromEnv(), - 'vhost' => EnvVars::RABBITMQ_VHOST()->loadFromEnv('/'), - ], + 'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false), + 'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(), + 'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'), + 'user' => EnvVars::RABBITMQ_USER->loadFromEnv(), + 'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(), + 'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'), - 'dependencies' => [ - 'factories' => [ - AMQPStreamConnection::class => ConfigAbstractFactory::class, - ], - 'delegators' => [ - AMQPStreamConnection::class => [ - LazyServiceFactory::class, - ], - ], - 'lazy_services' => [ - 'class_map' => [ - AMQPStreamConnection::class => AMQPStreamConnection::class, - ], - ], - ], - - ConfigAbstractFactory::class => [ - AMQPStreamConnection::class => [ - 'config.rabbitmq.host', - 'config.rabbitmq.port', - 'config.rabbitmq.user', - 'config.rabbitmq.password', - 'config.rabbitmq.vhost', - ], + // Deprecated + 'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false), ], ]; diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 08439b2a..426bb2ac 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -10,14 +10,14 @@ use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; return [ 'not_found_redirects' => [ - 'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT()->loadFromEnv(), - 'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT()->loadFromEnv(), - 'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT()->loadFromEnv(), + 'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(), + 'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(), + 'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(), ], 'redirects' => [ - 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE()->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE), - 'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME()->loadFromEnv( + 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE), + 'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv( DEFAULT_REDIRECT_CACHE_LIFETIME, ), ], diff --git a/config/autoload/redis.global.php b/config/autoload/redis.global.php index f87d77f3..1d035055 100644 --- a/config/autoload/redis.global.php +++ b/config/autoload/redis.global.php @@ -5,17 +5,23 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; return (static function (): array { - $redisServers = EnvVars::REDIS_SERVERS()->loadFromEnv(); + $redisServers = EnvVars::REDIS_SERVERS->loadFromEnv(); + $pubSub = [ + 'redis' => [ + 'pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false), + ], + ]; return match ($redisServers) { - null => [], + null => $pubSub, default => [ 'cache' => [ 'redis' => [ 'servers' => $redisServers, - 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE()->loadFromEnv(), + 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), ], ], + ...$pubSub, ], }; })(); diff --git a/config/autoload/redis.local.php.local b/config/autoload/redis.local.php.local index 08dbae32..9bd8fea6 100644 --- a/config/autoload/redis.local.php.local +++ b/config/autoload/redis.local.php.local @@ -7,12 +7,13 @@ return [ 'cache' => [ 'redis' => [ 'servers' => 'tcp://shlink_redis:6379', -// 'servers' => [ -// 'tcp://shlink_redis:6379', -// ], ], ], + 'redis' => [ + 'pub_sub_enabled' => true, + ], + 'dependencies' => [ 'aliases' => [ // With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default diff --git a/config/autoload/request_id.global.php b/config/autoload/request_id.global.php index f057bb09..5525849a 100644 --- a/config/autoload/request_id.global.php +++ b/config/autoload/request_id.global.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use PhpMiddleware\RequestId; +use Shlinkio\Shlink\Common\Logger\Processor\BackwardsCompatibleMonologProcessorDelegator; return [ @@ -20,6 +21,11 @@ return [ RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class, RequestId\MonologProcessor::class => ConfigAbstractFactory::class, ], + 'delegators' => [ + RequestId\MonologProcessor::class => [ + BackwardsCompatibleMonologProcessorDelegator::class, + ], + ], ], ConfigAbstractFactory::class => [ diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php index fd1f9525..8b5e856e 100644 --- a/config/autoload/router.global.php +++ b/config/autoload/router.global.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars; return [ 'router' => [ - 'base_path' => EnvVars::BASE_PATH()->loadFromEnv(''), + 'base_path' => EnvVars::BASE_PATH->loadFromEnv(''), 'fastroute' => [ FastRouteRouter::CONFIG_CACHE_ENABLED => true, diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php new file mode 100644 index 00000000..298b9349 --- /dev/null +++ b/config/autoload/routes.config.php @@ -0,0 +1,103 @@ + [ + // Rest + ...ConfigProvider::applyRoutesPrefix([ + Action\HealthAction::getRouteDef(), + + // Visits + Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), + Action\Visit\TagVisitsAction::getRouteDef(), + Action\Visit\DomainVisitsAction::getRouteDef(), + Action\Visit\GlobalVisitsAction::getRouteDef(), + Action\Visit\OrphanVisitsAction::getRouteDef(), + Action\Visit\NonOrphanVisitsAction::getRouteDef(), + + // Short URLs + Action\ShortUrl\CreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $dropDomainMiddleware, + $overrideDomainMiddleware, + Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, + ]), + Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $overrideDomainMiddleware, + ]), + Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ListShortUrlsAction::getRouteDef(), + + // Tags + Action\Tag\ListTagsAction::getRouteDef(), + Action\Tag\TagsStatsAction::getRouteDef(), + Action\Tag\DeleteTagsAction::getRouteDef(), + Action\Tag\UpdateTagAction::getRouteDef(), + + // Domains + Action\Domain\ListDomainsAction::getRouteDef(), + Action\Domain\DomainRedirectsAction::getRouteDef(), + + Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]), + ]), + + // Non-rest + [ + 'name' => CoreAction\RobotsAction::class, + 'path' => '/robots.txt', + 'middleware' => [ + CoreAction\RobotsAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + [ + 'name' => CoreAction\PixelAction::class, + 'path' => '/{shortCode}/track', + 'middleware' => [ + IpAddress::class, + CoreAction\PixelAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + [ + 'name' => CoreAction\QrCodeAction::class, + 'path' => '/{shortCode}/qr-code', + 'middleware' => [ + CoreAction\QrCodeAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + [ + 'name' => CoreAction\RedirectAction::class, + 'path' => '/{shortCode}', + 'middleware' => [ + IpAddress::class, + CoreAction\RedirectAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + ], + + ]; +})(); diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index 9d2c423f..36cba24f 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -6,8 +6,8 @@ use Shlinkio\Shlink\Core\Config\EnvVars; use const Shlinkio\Shlink\MIN_TASK_WORKERS; -return (static function () { - $taskWorkers = (int) EnvVars::TASK_WORKER_NUM()->loadFromEnv(16); +return (static function (): array { + $taskWorkers = (int) EnvVars::TASK_WORKER_NUM->loadFromEnv(16); return [ @@ -17,11 +17,11 @@ return (static function () { 'swoole-http-server' => [ 'host' => '0.0.0.0', - 'port' => (int) EnvVars::PORT()->loadFromEnv(8080), + 'port' => (int) EnvVars::PORT->loadFromEnv(8080), 'process-name' => 'shlink', 'options' => [ - 'worker_num' => (int) EnvVars::WEB_WORKER_NUM()->loadFromEnv(16), + 'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16), 'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS), ], ], diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php index b2596830..0637301a 100644 --- a/config/autoload/tracking.global.php +++ b/config/autoload/tracking.global.php @@ -9,28 +9,28 @@ return [ 'tracking' => [ // Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations // This applies only if IP address tracking is enabled - 'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR()->loadFromEnv(true), + 'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true), // Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence - 'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS()->loadFromEnv(true), + 'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true), // A query param that, if provided, will disable tracking of one particular visit. Always takes precedence - 'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM()->loadFromEnv(), + 'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(), // If true, visits will not be tracked at all - 'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING()->loadFromEnv(false), + 'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false), // If true, visits will be tracked, but neither the IP address, nor the location will be resolved - 'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING()->loadFromEnv(false), + 'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false), // If true, the referrer will not be tracked - 'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING()->loadFromEnv(false), + 'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false), // If true, the user agent will not be tracked - 'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING()->loadFromEnv(false), + 'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false), // A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default - 'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM()->loadFromEnv(), + 'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM->loadFromEnv(), ], ]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 25de914a..bf9ecb93 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -9,20 +9,21 @@ use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; return (static function (): array { $shortCodesLength = max( - (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH()->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH), + (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH), MIN_SHORT_CODES_LENGTH, ); return [ 'url_shortener' => [ - 'domain' => [ - 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http', - 'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''), + 'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain + 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv(true)) ? 'https' : 'http', + 'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''), ], 'default_short_codes_length' => $shortCodesLength, - 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES()->loadFromEnv(false), - 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH()->loadFromEnv(false), + 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false), + 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), + 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), ], ]; diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index 20140a9b..0069ffa9 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -12,6 +12,7 @@ return [ 'hostname' => sprintf('localhost:%s', $isSwoole ? '8080' : '8000'), ], 'auto_resolve_titles' => true, +// 'multi_segment_slugs_enabled' => true, ], ]; diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php index 5de7c53b..e72c4904 100644 --- a/config/autoload/webhooks.global.php +++ b/config/autoload/webhooks.global.php @@ -6,14 +6,14 @@ use Shlinkio\Shlink\Core\Config\EnvVars; // Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0 return (static function (): array { - $webhooks = EnvVars::VISITS_WEBHOOKS()->loadFromEnv(); + $webhooks = EnvVars::VISITS_WEBHOOKS->loadFromEnv(); return [ 'visits_webhooks' => [ 'webhooks' => $webhooks === null ? [] : explode(',', $webhooks), 'notify_orphan_visits_to_webhooks' => - (bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()->loadFromEnv(false), + (bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false), ], ]; diff --git a/config/cli-app.php b/config/cli-app.php index a2272852..9287cbaf 100644 --- a/config/cli-app.php +++ b/config/cli-app.php @@ -5,7 +5,7 @@ declare(strict_types=1); use Psr\Container\ContainerInterface; use Symfony\Component\Console\Application as CliApp; -return (static function () { +return (static function (): CliApp { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; return $container->get(CliApp::class); diff --git a/config/config.php b/config/config.php index 3dad2105..6c38707d 100644 --- a/config/config.php +++ b/config/config.php @@ -21,7 +21,7 @@ $isTestEnv = env('APP_ENV') === 'test'; return (new ConfigAggregator\ConfigAggregator([ ! $isTestEnv - ? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::cases()) + ? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::values()) : new ConfigAggregator\ArrayProvider([]), Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, @@ -43,6 +43,9 @@ return (new ConfigAggregator\ConfigAggregator([ $isTestEnv ? new ConfigAggregator\PhpFileProvider('config/test/*.global.php') : new ConfigAggregator\ArrayProvider([]), + // Routes have to be loaded last + new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), ], 'data/cache/app_config.php', [ Core\Config\BasePathPrefixer::class, + Core\Config\MultiSegmentSlugProcessor::class, ]))->getMergedConfig(); diff --git a/config/constants.php b/config/constants.php index a7bd0bb7..d3d869c3 100644 --- a/config/constants.php +++ b/config/constants.php @@ -19,3 +19,4 @@ const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const MIN_TASK_WORKERS = 4; +const MIGRATIONS_TABLE = 'migrations'; diff --git a/config/container.php b/config/container.php index 56fb345d..6e95e84d 100644 --- a/config/container.php +++ b/config/container.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Laminas\ServiceManager\ServiceManager; +use Shlinkio\Shlink\Core\Config\EnvVars; use Symfony\Component\Lock; use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY; @@ -11,6 +12,9 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; +// This is one of the first files loaded. Configure the timezone here +date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get())); + // This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name // It needs to be placed here as individual config files will not be loaded once config is cached if (! class_exists(LOCAL_LOCK_FACTORY)) { @@ -18,7 +22,7 @@ if (! class_exists(LOCAL_LOCK_FACTORY)) { } // Build container -return (function () { +return (static function (): ServiceManager { $config = require __DIR__ . '/config.php'; $container = new ServiceManager($config['dependencies']); $container->setService('config', $config); diff --git a/config/entity-manager.php b/config/entity-manager.php index 2b4794f7..6721fec3 100644 --- a/config/entity-manager.php +++ b/config/entity-manager.php @@ -3,9 +3,10 @@ declare(strict_types=1); use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; use Psr\Container\ContainerInterface; -return (static function () { +return (static function (): EntityManagerInterface { /** @var ContainerInterface $container */ $container = include __DIR__ . '/container.php'; return $container->get(EntityManager::class); diff --git a/config/test/bootstrap_db_tests.php b/config/test/bootstrap_db_tests.php index 9f14c38d..0237d741 100644 --- a/config/test/bootstrap_db_tests.php +++ b/config/test/bootstrap_db_tests.php @@ -8,5 +8,5 @@ use Psr\Container\ContainerInterface; /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; -$container->get(Helper\TestHelper::class)->createTestDb(); +$container->get(Helper\TestHelper::class)->createTestDb(['bin/cli', 'db:create'], ['bin/cli', 'db:migrate']); DbTest\DatabaseTestCase::setEntityManager($container->get('em')); diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 838531de..ddd7631d 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -10,7 +10,7 @@ use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; use League\Event\EventDispatcher; use Monolog\Handler\StreamHandler; -use Monolog\Logger; +use Monolog\Level; use PHPUnit\Runner\Version; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; @@ -26,6 +26,7 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Shlinkio\Shlink\Common\Logger\LoggerType; use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; @@ -107,16 +108,10 @@ $buildDbConnection = static function (): array { }; }; -$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [ - 'handlers' => [ - $handlerName => [ - 'name' => StreamHandler::class, - 'params' => [ - 'level' => Logger::DEBUG, - 'stream' => sprintf('data/log/api-tests/%s', $filename), - ], - ], - ], +$buildTestLoggerConfig = static fn (string $filename) => [ + 'level' => Level::Debug->value, + 'type' => LoggerType::STREAM->value, + 'destination' => sprintf('data/log/api-tests/%s', $filename), ]; return [ @@ -262,8 +257,8 @@ return [ ], 'logger' => [ - 'Shlink' => $buildTestLoggerConfig('shlink_handler', 'shlink.log'), - 'Access' => $buildTestLoggerConfig('access_handler', 'access.log'), + 'Shlink' => $buildTestLoggerConfig('shlink.log'), + 'Access' => $buildTestLoggerConfig('access.log'), ], ]; diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 80ff8afd..5e05481a 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.0-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index ee34034d..a2066752 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,15 +1,14 @@ -FROM php:8.1.1-fpm-alpine3.15 +FROM php:8.1.9-fpm-alpine3.16 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.10.0 +ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 RUN apk update # Install common php extensions RUN docker-php-ext-install pdo_mysql -RUN docker-php-ext-install iconv RUN docker-php-ext-install calendar RUN apk add --no-cache oniguruma-dev diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 24655a4f..21a2fe5e 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,17 +1,16 @@ -FROM php:8.1.1-alpine3.15 +FROM php:8.1.9-alpine3.16 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 4.9.1 -ENV PDO_SQLSRV_VERSION 5.10.0 +ENV OPENSWOOLE_VERSION 4.11.1 +ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 RUN apk update # Install common php extensions RUN docker-php-ext-install pdo_mysql -RUN docker-php-ext-install iconv RUN docker-php-ext-install calendar RUN apk add --no-cache oniguruma-dev diff --git a/data/migrations/Version20210207100807.php b/data/migrations/Version20210207100807.php index 706132cc..cd0b0b12 100644 --- a/data/migrations/Version20210207100807.php +++ b/data/migrations/Version20210207100807.php @@ -8,8 +8,8 @@ use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; -use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; final class Version20210207100807 extends AbstractMigration { @@ -27,7 +27,7 @@ final class Version20210207100807 extends AbstractMigration ]); $visits->addColumn('type', Types::STRING, [ 'length' => 255, - 'default' => Visit::TYPE_VALID_SHORT_URL, + 'default' => VisitType::VALID_SHORT_URL->value, ]); } diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 73cc3fdc..4fba24b6 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -4,22 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Monolog\Handler\StreamHandler; -use Monolog\Logger; +use Shlinkio\Shlink\Common\Logger\LoggerType; return [ 'logger' => [ 'Shlink' => [ - 'handlers' => [ - 'shlink_handler' => [ - 'name' => StreamHandler::class, - 'params' => [ - 'level' => Logger::INFO, - 'stream' => 'php://stdout', - ], - ], - ], + 'type' => LoggerType::STREAM->value, ], ], diff --git a/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md b/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md index 4c3b6c52..16dea9d3 100644 --- a/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md +++ b/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md @@ -16,7 +16,7 @@ The intention is to implement a system that allows adding to API keys as many of Supporting more restrictions in the future is also desirable. -## Considered option +## Considered options * Using an ACL/RBAC library, and checking roles in a middleware. * Using a service that, provided an API key, tells if certain resource is reachable while it also allows building queries dynamically. diff --git a/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md index 983410d1..f4e5a288 100644 --- a/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md +++ b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md @@ -11,7 +11,7 @@ However, it does not track visits to any of those, just to valid short URLs. The intention is to change that, and allow users to track the cases mentioned above. -## Considered option +## Considered options * Create a new table to track visits o this kind. * Reuse the existing `visits` table, by making `short_url_id` nullable and adding a couple of other fields. diff --git a/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md b/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md index aa19f160..11cf4fc6 100644 --- a/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md +++ b/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md @@ -13,7 +13,7 @@ However, after the creation of the caching PSRs ([PSR-6 - Cache](https://www.php Also, Shlink needs support for Redis clusters and Redis sentinels, which is not supported by `doctrine/cache` Redis adapters. -## Considered option +## Considered options After some research, the only packages that seem to support the capabilities required by Shlink and also seem healthy, are these: diff --git a/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md b/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md index df11538c..e5b72b09 100644 --- a/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md +++ b/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md @@ -11,7 +11,7 @@ It is potentially possible to combine both, but if you do so, you will find out A [Twitter survey](https://twitter.com/shlinkio/status/1480614855006732289) has also showed up all participants also found the behavior should be the opposite. -## Considered option +## Considered options * Move the logic to read env vars to another config file which always overrides installer options. * Move the logic to read env vars to a config post-processor which overrides config dynamically, only if the appropriate env var had been defined. diff --git a/docs/adr/2022-08-05-support-multi-segment-custom-slugs.md b/docs/adr/2022-08-05-support-multi-segment-custom-slugs.md new file mode 100644 index 00000000..99d82668 --- /dev/null +++ b/docs/adr/2022-08-05-support-multi-segment-custom-slugs.md @@ -0,0 +1,42 @@ +# Support multi-segment custom slugs + +* Status: Accepted +* Date: 2022-08-05 + +## Context and problem statement + +There's a new requirement to support multi-segment custom slugs (as in `https://exam.ple/foo/bar/baz`). + +The internal router does not support this at the moment, as it only matches the shortCode in one of the segments. + +## Considered options + +* Tweak the internal router, so that it is capable of matching multiple segments for the slug, in every route that requires it. +* Define a new set of routes with a short prefix that allows configuring multi-segment in those, without touching the existing routes. +* Let the router fail, and use a middleware to fall back to the proper route (similar to what was done for the extra path forwarding feature). + +## Decision outcome + +Even though I was initially inclined to use a fallback middleware, that has turned out to be harder than anticipated, because there are several possible routes where the slug is used, and we would still need some kind of router to determine which one matches. + +Because of that, the selected approach has been to tweak the existing router, so that it can match multiple segments, and moving the configuration of routes to a common place so that they can be defined in the proper order that prevents conflicts. + +## Pros and Cons of the Options + +### Tweaking the router + +* Bad: It requires routes to be defined in a specific order, and remember it in the future if more routes are added. +* Good: It initially requires fewer changes. +* Good: Once routes are defined in the proper order, all the internal logic works out of the box. + +### Defining new routes + +* Bad: The end-user experience gets affected. +* Bad: Probably a lot of side effects would happen when it comes to assembling short URLs. +* Bad: Routing needs to be configured twice, resolving the same logic. +* Bad: It turns out to still conflict with some routes, even with the prefix, which defeats what looked like its main benefit. + +### Let routing fail and fall back in middleware + +* Good: Does not require changing routes configuration, which means less side effects. +* Bad: Since many routes can potentially end up in the middleware, there's still the need to have some kind of routing logic. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8fd4a662..7cfccdf7 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. +* [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) * [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md) * [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 82da91c5..3b59e8e5 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -1,8 +1,8 @@ { - "asyncapi": "2.0.0", + "asyncapi": "2.4.0", "info": { "title": "Shlink", - "version": "2.0.0", + "version": "3.0.0", "description": "Shlink, the self-hosted URL shortener", "license": { "name": "MIT", @@ -75,6 +75,23 @@ } } } + }, + "https://shlink.io/new-short-url": { + "subscribe": { + "summary": "Receive information about any new short URL.", + "operationId": "newshortUrl", + "message": { + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "shortUrl": { + "$ref": "#/components/schemas/ShortUrl" + } + } + } + } + } } }, "components": { @@ -101,7 +118,7 @@ }, "visitsCount": { "type": "integer", - "description": "The number of visits that this short URL has recieved." + "description": "The number of visits that this short URL has received." }, "tags": { "type": "array", diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index a5dee481..f09e8d7b 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -33,7 +33,7 @@ }, "visitsCount": { "type": "integer", - "description": "The number of visits that this short URL has recieved." + "description": "The number of visits that this short URL has received." }, "tags": { "type": "array", diff --git a/docs/swagger/examples/short-url-not-found.json b/docs/swagger/examples/short-url-not-found.json index 74a5661c..4a58c847 100644 --- a/docs/swagger/examples/short-url-not-found.json +++ b/docs/swagger/examples/short-url-not-found.json @@ -1,7 +1,7 @@ { "value": { - "detail":"No URL found with short code \"abc123\"", - "title":"Short URL not found", + "detail": "No URL found with short code \"abc123\"", + "title": "Short URL not found", "type": "INVALID_SHORTCODE", "status": 404, "shortCode": "abc123" diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 2f7a9600..9065ff89 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -312,7 +312,7 @@ }, "threshold": { "type": "number", - "description": "The amount of visits currently configured as threshold to allow deleting short UYRLs or not" + "description": "The amount of visits currently configured as threshold to allow deleting short URLs or not" } } } diff --git a/docs/swagger/paths/v2_domains_{domain}_visits.json b/docs/swagger/paths/v2_domains_{domain}_visits.json new file mode 100644 index 00000000..33389f32 --- /dev/null +++ b/docs/swagger/paths/v2_domains_{domain}_visits.json @@ -0,0 +1,172 @@ +{ + "get": { + "operationId": "getDomainVisits", + "tags": [ + "Visits" + ], + "summary": "List visits for domain", + "description": "Get the list of visits on any short URL which belongs to provided domain.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "name": "domain", + "in": "path", + "description": "The domain from which we want to get the visits, or **DEFAULT** keyword for default domain.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "startDate", + "in": "query", + "description": "The date (in ISO-8601 format) from which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endDate", + "in": "query", + "description": "The date (in ISO-8601 format) until which we want to get visits.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page to display. Defaults to 1", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The amount of items to return on every page. Defaults to all the items", + "required": false, + "schema": { + "type": "number" + } + }, + { + "name": "excludeBots", + "in": "query", + "description": "Tells if visits from potential bots should be excluded from the result set", + "required": false, + "schema": { + "type": "string", + "enum": ["true"] + } + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "List of visits.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "visits": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "../definitions/Visit.json" + } + }, + "pagination": { + "$ref": "../definitions/Pagination.json" + } + } + } + } + }, + "example": { + "visits": { + "data": [ + { + "referer": "https://twitter.com", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0", + "visitLocation": null, + "potentialBot": false + }, + { + "referer": "https://t.co", + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", + "visitLocation": { + "cityName": "Cupertino", + "countryCode": "US", + "countryName": "United States", + "latitude": 37.3042, + "longitude": -122.0946, + "regionName": "California", + "timezone": "America/Los_Angeles" + }, + "potentialBot": false + }, + { + "referer": null, + "date": "2015-08-20T05:05:03+04:00", + "userAgent": "some_web_crawler/1.4", + "visitLocation": null, + "potentialBot": true + } + ], + "pagination": { + "currentPage": 5, + "pagesCount": 12, + "itemsPerPage": 10, + "itemsInCurrentPage": 10, + "totalItems": 115 + } + } + } + } + } + }, + "404": { + "description": "The domain does not exist.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + }, + "example": { + "detail": "Domain with authority \"example.com\" could not be found", + "title": "Domain not found", + "type": "DOMAIN_NOT_FOUND", + "status": 404, + "authority": "example.com" + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 104860eb..dd5c8b8a 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -53,7 +53,7 @@ { "name": "errorCorrection", "in": "query", - "description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).", + "description": "The error correction level to apply to the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).", "required": false, "schema": { "type": "string", diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 3730b527..840ac84e 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", - "version": "1.0" + "version": "2.0" }, "externalDocs": { @@ -95,6 +95,9 @@ "/rest/v{version}/tags/{tag}/visits": { "$ref": "paths/v2_tags_{tag}_visits.json" }, + "/rest/v{version}/domains/{domain}/visits": { + "$ref": "paths/v2_domains_{domain}_visits.json" + }, "/rest/v{version}/visits/orphan": { "$ref": "paths/v2_visits_orphan.json" }, diff --git a/migrations.php b/migrations.php index 306c1c08..78369f6a 100644 --- a/migrations.php +++ b/migrations.php @@ -2,13 +2,15 @@ declare(strict_types=1); +use const Shlinkio\Shlink\MIGRATIONS_TABLE; + return [ 'migrations_paths' => [ 'ShlinkMigrations' => 'data/migrations', ], 'table_storage' => [ - 'table_name' => 'migrations', + 'table_name' => MIGRATIONS_TABLE, ], 'custom_template' => 'data/migrations_template.txt', diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 2b5b5afd..7629d855 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -11,11 +11,13 @@ return [ Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class, Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class, Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class, - Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class, + Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class, Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class, Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class, Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class, + Command\Visit\GetOrphanVisitsCommand::NAME => Command\Visit\GetOrphanVisitsCommand::class, + Command\Visit\GetNonOrphanVisitsCommand::NAME => Command\Visit\GetNonOrphanVisitsCommand::class, Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class, Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, @@ -24,9 +26,11 @@ return [ Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class, Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class, + Command\Tag\GetTagVisitsCommand::NAME => Command\Tag\GetTagVisitsCommand::class, Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class, Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class, + Command\Domain\GetDomainVisitsCommand::NAME => Command\Domain\GetDomainVisitsCommand::class, Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index da23b0f6..6920e839 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options\TrackingOptions; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; @@ -42,11 +43,13 @@ return [ Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class, - Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class, + Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, + Command\Visit\GetOrphanVisitsCommand::class => ConfigAbstractFactory::class, + Command\Visit\GetNonOrphanVisitsCommand::class => ConfigAbstractFactory::class, Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class, Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class, @@ -55,12 +58,14 @@ return [ Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class, Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class, Command\Tag\DeleteTagsCommand::class => ConfigAbstractFactory::class, + Command\Tag\GetTagVisitsCommand::class => ConfigAbstractFactory::class, Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class, Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class, Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class, + Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class, ], ], @@ -72,20 +77,19 @@ return [ TrackingOptions::class, ], Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class], - ApiKey\RoleResolver::class => [DomainService::class], + ApiKey\RoleResolver::class => [DomainService::class, 'config.url_shortener.domain.hostname'], Command\ShortUrl\CreateShortUrlCommand::class => [ Service\UrlShortener::class, ShortUrlStringifier::class, - 'config.url_shortener.default_short_codes_length', - 'config.url_shortener.domain.hostname', + UrlShortenerOptions::class, ], Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [ Service\ShortUrlService::class, ShortUrlDataTransformer::class, ], - Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class], + Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class], @@ -94,6 +98,8 @@ return [ IpLocationResolverInterface::class, LockFactory::class, ], + Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class], + Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class], @@ -102,9 +108,11 @@ return [ Command\Tag\ListTagsCommand::class => [TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class], + Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], Command\Domain\ListDomainsCommand::class => [DomainService::class], Command\Domain\DomainRedirectsCommand::class => [DomainService::class], + Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index c8cccfc6..588a2fa2 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\ApiKey; +use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Symfony\Component\Console\Input\InputInterface; @@ -12,24 +13,33 @@ use function is_string; class RoleResolver implements RoleResolverInterface { - public function __construct(private DomainServiceInterface $domainService) + public function __construct(private DomainServiceInterface $domainService, private string $defaultDomain) { } public function determineRoles(InputInterface $input): array { - $domainAuthority = $input->getOption('domain-only'); - $author = $input->getOption('author-only'); + $domainAuthority = $input->getOption(self::DOMAIN_ONLY_PARAM); + $author = $input->getOption(self::AUTHOR_ONLY_PARAM); $roleDefinitions = []; if ($author) { $roleDefinitions[] = RoleDefinition::forAuthoredShortUrls(); } if (is_string($domainAuthority)) { - $domain = $this->domainService->getOrCreate($domainAuthority); - $roleDefinitions[] = RoleDefinition::forDomain($domain); + $roleDefinitions[] = $this->resolveRoleForAuthority($domainAuthority); } return $roleDefinitions; } + + private function resolveRoleForAuthority(string $domainAuthority): RoleDefinition + { + if ($domainAuthority === $this->defaultDomain) { + throw InvalidRoleConfigException::forDomainOnlyWithDefaultDomain(); + } + + $domain = $this->domainService->getOrCreate($domainAuthority); + return RoleDefinition::forDomain($domain); + } } diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 2655d1fb..b24619ef 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -73,13 +73,16 @@ class GenerateKeyCommand extends Command $authorOnly, 'a', InputOption::VALUE_NONE, - sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS), + sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value), ) ->addOption( $domainOnly, 'd', InputOption::VALUE_REQUIRED, - sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC), + sprintf( + 'Adds the "%s" role to the new API key, with the domain provided.', + Role::DOMAIN_SPECIFIC->value, + ), ) ->setHelp($help); } @@ -99,7 +102,7 @@ class GenerateKeyCommand extends Command if (! $apiKey->isAdmin()) { ShlinkTable::default($io)->render( ['Role name', 'Role metadata'], - $apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]), + $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]), null, 'Roles', ); diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 0a331086..0e98af31 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -60,10 +60,10 @@ class ListKeysCommand extends Command } $rowData[] = $expiration?->toAtomString() ?? '-'; $rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( - fn (string $roleName, array $meta) => + fn (Role $role, array $meta) => empty($meta) - ? Role::toFriendlyName($roleName) - : sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)), + ? Role::toFriendlyName($role) + : sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)), )); return $rowData; diff --git a/module/CLI/src/Command/Db/CreateDatabaseCommand.php b/module/CLI/src/Command/Db/CreateDatabaseCommand.php index 45880338..415290a3 100644 --- a/module/CLI/src/Command/Db/CreateDatabaseCommand.php +++ b/module/CLI/src/Command/Db/CreateDatabaseCommand.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Db; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface; use Symfony\Component\Console\Input\InputInterface; @@ -14,6 +15,9 @@ use Symfony\Component\Lock\LockFactory; use Symfony\Component\Process\PhpExecutableFinder; use function Functional\contains; +use function Functional\filter; + +use const Shlinkio\Shlink\MIGRATIONS_TABLE; class CreateDatabaseCommand extends AbstractDatabaseCommand { @@ -62,7 +66,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand private function checkDbExists(): void { - if ($this->regularConn->getDatabasePlatform()->getName() === 'sqlite') { + if ($this->regularConn->getDriver()->getDatabasePlatform() instanceof SqlitePlatform) { return; } @@ -70,7 +74,7 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand // Otherwise, it will fail to connect and will not be able to create the new database $schemaManager = $this->noDbNameConn->createSchemaManager(); $databases = $schemaManager->listDatabases(); - $shlinkDatabase = $this->regularConn->getDatabase(); + $shlinkDatabase = $this->regularConn->getParams()['dbname'] ?? null; if ($shlinkDatabase !== null && ! contains($databases, $shlinkDatabase)) { $schemaManager->createDatabase($shlinkDatabase); @@ -80,8 +84,9 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand private function schemaExists(): bool { // If at least one of the shlink tables exist, we will consider the database exists somehow. - // Any inconsistency should be taken care by the migrations + // We exclude the migrations table, in case db:migrate was run first by mistake. + // Any other inconsistency will be taken care by the migrations. $schemaManager = $this->regularConn->createSchemaManager(); - return ! empty($schemaManager->listTableNames()); + return ! empty(filter($schemaManager->listTableNames(), fn (string $table) => $table !== MIGRATIONS_TABLE)); } } diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index 90cfd1f7..c546fd5b 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -53,7 +53,7 @@ class DomainRedirectsCommand extends Command /** @var string[] $availableDomains */ $availableDomains = invoke( - filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()), + filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault), 'toString', ); if (empty($availableDomains)) { diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php new file mode 100644 index 00000000..00c811c1 --- /dev/null +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -0,0 +1,50 @@ +setName(self::NAME) + ->setDescription('Returns the list of visits for provided domain.') + ->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + $domain = $input->getArgument('domain'); + return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + $shortUrl = $visit->getShortUrl(); + return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; + } +} diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 447bf92f..8f2ee22c 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -48,12 +48,12 @@ class ListDomainsCommand extends Command $table->render( $showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields, map($domains, function (DomainItem $domain) use ($showRedirects) { - $commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']; + $commonValues = [$domain->toString(), $domain->isDefault ? 'Yes' : 'No']; return $showRedirects ? [ ...$commonValues, - $this->notFoundRedirectsToString($domain->notFoundRedirectConfig()), + $this->notFoundRedirectsToString($domain->notFoundRedirectConfig), ] : $commonValues; }), diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 3334ae6a..6b4cce1a 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; @@ -19,6 +21,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function array_map; +use function explode; use function Functional\curry; use function Functional\flatten; use function Functional\unique; @@ -29,14 +32,15 @@ class CreateShortUrlCommand extends Command public const NAME = 'short-url:create'; private ?SymfonyStyle $io; + private string $defaultDomain; public function __construct( - private UrlShortenerInterface $urlShortener, - private ShortUrlStringifierInterface $stringifier, - private int $defaultShortCodeLength, - private string $defaultDomain, + private readonly UrlShortenerInterface $urlShortener, + private readonly ShortUrlStringifierInterface $stringifier, + private readonly UrlShortenerOptions $options, ) { parent::__construct(); + $this->defaultDomain = $this->options->domain()['hostname'] ?? ''; } protected function configure(): void @@ -150,11 +154,11 @@ class CreateShortUrlCommand extends Command return ExitCodes::EXIT_FAILURE; } - $explodeWithComma = curry('explode')(','); + $explodeWithComma = curry(explode(...))(','); $tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); $customSlug = $input->getOption('custom-slug'); $maxVisits = $input->getOption('max-visits'); - $shortCodeLength = $input->getOption('short-code-length') ?? $this->defaultShortCodeLength; + $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength(); $doValidateUrl = $input->getOption('validate-url'); try { @@ -171,6 +175,7 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), + EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled(), ])); $io->writeln([ diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index fc4e8331..db1b1dfd 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -81,6 +81,6 @@ class DeleteShortUrlCommand extends Command private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void { $this->deleteShortUrlService->deleteByShortCode($identifier, $ignoreThreshold); - $io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode())); + $io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode)); } } diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php new file mode 100644 index 00000000..49c390f8 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -0,0 +1,59 @@ +setName(self::NAME) + ->setDescription('Returns the detailed visits information for provided short code') + ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.') + ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $shortCode = $input->getArgument('shortCode'); + if (! empty($shortCode)) { + return; + } + + $io = new SymfonyStyle($input, $output); + $shortCode = $io->ask('A short code was not provided. Which short code do you want to use?'); + if (! empty($shortCode)) { + $input->setArgument('shortCode', $shortCode); + } + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + $identifier = ShortUrlIdentifier::fromCli($input); + return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + return []; + } +} diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php deleted file mode 100644 index bb2f0229..00000000 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ /dev/null @@ -1,88 +0,0 @@ -setName(self::NAME) - ->setDescription('Returns the detailed visits information for provided short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.') - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.'); - } - - protected function getStartDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName); - } - - protected function getEndDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $shortCode = $input->getArgument('shortCode'); - if (! empty($shortCode)) { - return; - } - - $io = new SymfonyStyle($input, $output); - $shortCode = $io->ask('A short code was not provided. Which short code do you want to use?'); - if (! empty($shortCode)) { - $input->setArgument('shortCode', $shortCode); - } - } - - protected function execute(InputInterface $input, OutputInterface $output): ?int - { - $identifier = ShortUrlIdentifier::fromCli($input); - $startDate = $this->getStartDateOption($input, $output); - $endDate = $this->getEndDateOption($input, $output); - - $paginator = $this->visitsHelper->visitsForShortUrl( - $identifier, - new VisitsParams(buildDateRange($startDate, $endDate)), - ); - - $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) { - $rowData = $visit->jsonSerialize(); - $rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName(); - return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']); - }); - ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows); - - return ExitCodes::EXIT_SUCCESS; - } -} diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 751006bf..fc0f19a0 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -120,9 +121,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('search-term'); $tags = $input->getOption('tags'); - $tagsMode = $input->getOption('including-all-tags') === true - ? ShortUrlsParams::TAGS_MODE_ALL - : ShortUrlsParams::TAGS_MODE_ANY; + $tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $tags = ! empty($tags) ? explode(',', $tags) : []; $all = $input->getOption('all'); $startDate = $this->getStartDateOption($input, $output); @@ -209,7 +208,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand } if ($input->getOption('show-api-key')) { $columnsMap['API Key'] = static fn (array $_, ShortUrl $shortUrl): string => - (string) $shortUrl->authorApiKey(); + $shortUrl->authorApiKey()?->__toString() ?? ''; } if ($input->getOption('show-api-key-name')) { $columnsMap['API Key Name'] = static fn (array $_, ShortUrl $shortUrl): ?string => diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php new file mode 100644 index 00000000..ac0157bc --- /dev/null +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -0,0 +1,50 @@ +setName(self::NAME) + ->setDescription('Returns the list of visits for provided tag.') + ->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + $tag = $input->getArgument('tag'); + return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + $shortUrl = $visit->getShortUrl(); + return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; + } +} diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 9c7269fa..cd820169 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -46,7 +46,7 @@ class ListTagsCommand extends Command return map( $tags, - static fn (TagInfo $tagInfo) => [$tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()], + static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount], ); } } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 23c1568d..85377a18 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -19,7 +19,7 @@ class RenameTagCommand extends Command { public const NAME = 'tag:rename'; - public function __construct(private TagServiceInterface $tagService) + public function __construct(private readonly TagServiceInterface $tagService) { parent::__construct(); } diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index 9482694b..d1e45fd8 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -14,7 +14,7 @@ use function sprintf; abstract class AbstractLockedCommand extends Command { - public function __construct(private LockFactory $locker) + public function __construct(private readonly LockFactory $locker) { parent::__construct(); } @@ -22,11 +22,11 @@ abstract class AbstractLockedCommand extends Command final protected function execute(InputInterface $input, OutputInterface $output): ?int { $lockConfig = $this->getLockConfig(); - $lock = $this->locker->createLock($lockConfig->lockName(), $lockConfig->ttl(), $lockConfig->isBlocking()); + $lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking); - if (! $lock->acquire($lockConfig->isBlocking())) { + if (! $lock->acquire($lockConfig->isBlocking)) { $output->writeln( - sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName()), + sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName), ); return ExitCodes::EXIT_WARNING; } diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockedCommandConfig.php index f053d99a..8e357329 100644 --- a/module/CLI/src/Command/Util/LockedCommandConfig.php +++ b/module/CLI/src/Command/Util/LockedCommandConfig.php @@ -9,9 +9,9 @@ final class LockedCommandConfig public const DEFAULT_TTL = 600.0; // 10 minutes private function __construct( - private string $lockName, - private bool $isBlocking, - private float $ttl = self::DEFAULT_TTL, + public readonly string $lockName, + public readonly bool $isBlocking, + public readonly float $ttl = self::DEFAULT_TTL, ) { } @@ -24,19 +24,4 @@ final class LockedCommandConfig { return new self($lockName, false); } - - public function lockName(): string - { - return $this->lockName; - } - - public function isBlocking(): bool - { - return $this->isBlocking; - } - - public function ttl(): float - { - return $this->ttl; - } } diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php new file mode 100644 index 00000000..257c7f26 --- /dev/null +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -0,0 +1,83 @@ +getStartDateOption($input, $output); + $endDate = $this->getEndDateOption($input, $output); + $paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate)); + [$rows, $headers] = $this->resolveRowsAndHeaders($paginator); + + ShlinkTable::default($output)->render($headers, $rows); + + return ExitCodes::EXIT_SUCCESS; + } + + private function resolveRowsAndHeaders(Paginator $paginator): array + { + $extraKeys = []; + $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) use (&$extraKeys) { + $extraFields = $this->mapExtraFields($visit); + $extraKeys = array_keys($extraFields); + + $rowData = [ + ...$visit->jsonSerialize(), + 'country' => $visit->getVisitLocation()?->getCountryName() ?? 'Unknown', + 'city' => $visit->getVisitLocation()?->getCityName() ?? 'Unknown', + ...$extraFields, + ]; + + return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); + }); + $extra = map($extraKeys, camelCaseToHumanFriendly(...)); + + return [ + $rows, + ['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra], + ]; + } + + abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator; + + /** + * @return array + */ + abstract protected function mapExtraFields(Visit $visit): array; +} diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php new file mode 100644 index 00000000..76c35990 --- /dev/null +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -0,0 +1,46 @@ +setName(self::NAME) + ->setDescription('Returns the list of non-orphan visits.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + $shortUrl = $visit->getShortUrl(); + return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; + } +} diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php new file mode 100644 index 00000000..ec675a69 --- /dev/null +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -0,0 +1,36 @@ +setName(self::NAME) + ->setDescription('Returns the list of orphan visits.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + return $this->visitsHelper->orphanVisits(new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + return ['type' => $visit->type()->value]; + } +} diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index de66e84e..fe898dbb 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -80,12 +81,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat ); } - if ($all && $retry && ! $this->warnAndVerifyContinue($input)) { + if ($all && $retry && ! $this->warnAndVerifyContinue()) { throw new RuntimeException('Execution aborted'); } } - private function warnAndVerifyContinue(InputInterface $input): bool + private function warnAndVerifyContinue(): bool { $this->io->warning([ 'You are about to process the location of all existing visits your short URLs received.', @@ -103,7 +104,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $all = $retry && $input->getOption('all'); try { - $this->checkDbUpdate($input); + $this->checkDbUpdate(); if ($all) { $this->visitLocator->locateAllVisits($this); @@ -166,7 +167,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $this->io->writeln($message); } - private function checkDbUpdate(InputInterface $input): void + private function checkDbUpdate(): void { $cliApp = $this->getApplication(); if ($cliApp === null) { @@ -174,7 +175,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat } $downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME); - $exitCode = $downloadDbCommand->run($input, $this->io); + $exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io); if ($exitCode === ExitCodes::EXIT_FAILURE) { throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.'); diff --git a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php index 0c5ef184..ef59d225 100644 --- a/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php +++ b/module/CLI/src/Exception/GeolocationDbUpdateFailedException.php @@ -13,7 +13,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc { private bool $olderDbExists; - private function __construct(string $message, int $code = 0, ?Throwable $previous = null) + private function __construct(string $message, int $code, ?Throwable $previous) { parent::__construct($message, $code, $previous); } @@ -47,7 +47,7 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc $e = new self(sprintf( 'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.', $buildEpoch, - )); + ), 0, null); $e->olderDbExists = true; return $e; diff --git a/module/CLI/src/Exception/InvalidRoleConfigException.php b/module/CLI/src/Exception/InvalidRoleConfigException.php new file mode 100644 index 00000000..ae483766 --- /dev/null +++ b/module/CLI/src/Exception/InvalidRoleConfigException.php @@ -0,0 +1,22 @@ +value, + )); + } +} diff --git a/module/CLI/src/Util/GeolocationDbUpdater.php b/module/CLI/src/Util/GeolocationDbUpdater.php index 67e9d485..22a3bac5 100644 --- a/module/CLI/src/Util/GeolocationDbUpdater.php +++ b/module/CLI/src/Util/GeolocationDbUpdater.php @@ -66,9 +66,8 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface { $buildTimestamp = $this->resolveBuildTimestamp($meta); $buildDate = Chronos::createFromTimestamp($buildTimestamp); - $now = Chronos::now(); - return $now->gt($buildDate->addDays(35)); + return Chronos::now()->gt($buildDate->addDays(35)); } private function resolveBuildTimestamp(Metadata $meta): int diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index 1d4143c1..cd38e5cd 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -15,7 +15,7 @@ final class ShlinkTable private const DEFAULT_STYLE_NAME = 'default'; private const TABLE_TITLE_STYLE = ' %s '; - private function __construct(private Table $baseTable, private bool $withRowSeparators) + private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators) { } diff --git a/module/CLI/test/ApiKey/RoleResolverTest.php b/module/CLI/test/ApiKey/RoleResolverTest.php index 5353ca72..3632a294 100644 --- a/module/CLI/test/ApiKey/RoleResolverTest.php +++ b/module/CLI/test/ApiKey/RoleResolverTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\ApiKey\RoleResolver; +use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -23,7 +24,7 @@ class RoleResolverTest extends TestCase protected function setUp(): void { $this->domainService = $this->prophesize(DomainServiceInterface::class); - $this->resolver = new RoleResolver($this->domainService->reveal()); + $this->resolver = new RoleResolver($this->domainService->reveal(), 'default.com'); } /** @@ -94,4 +95,16 @@ class RoleResolverTest extends TestCase 1, ]; } + + /** @test */ + public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void + { + $input = $this->prophesize(InputInterface::class); + $input->getOption(RoleResolver::DOMAIN_ONLY_PARAM)->willReturn('default.com'); + $input->getOption(RoleResolver::AUTHOR_ONLY_PARAM)->willReturn(null); + + $this->expectException(InvalidRoleConfigException::class); + + $this->resolver->determineRoles($input->reveal()); + } } diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index f77f6b79..93e07d4d 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\CLI\Command\Db; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Driver; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -19,6 +21,8 @@ use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; use Symfony\Component\Process\PhpExecutableFinder; +use const Shlinkio\Shlink\MIGRATIONS_TABLE; + class CreateDatabaseCommandTest extends TestCase { use CliTestUtilsTrait; @@ -27,7 +31,7 @@ class CreateDatabaseCommandTest extends TestCase private ObjectProphecy $processHelper; private ObjectProphecy $regularConn; private ObjectProphecy $schemaManager; - private ObjectProphecy $databasePlatform; + private ObjectProphecy $driver; public function setUp(): void { @@ -43,11 +47,12 @@ class CreateDatabaseCommandTest extends TestCase $this->processHelper = $this->prophesize(ProcessRunnerInterface::class); $this->schemaManager = $this->prophesize(AbstractSchemaManager::class); - $this->databasePlatform = $this->prophesize(AbstractPlatform::class); $this->regularConn = $this->prophesize(Connection::class); $this->regularConn->createSchemaManager()->willReturn($this->schemaManager->reveal()); - $this->regularConn->getDatabasePlatform()->willReturn($this->databasePlatform->reveal()); + $this->driver = $this->prophesize(Driver::class); + $this->regularConn->getDriver()->willReturn($this->driver->reveal()); + $this->driver->getDatabasePlatform()->willReturn($this->prophesize(AbstractPlatform::class)->reveal()); $noDbNameConn = $this->prophesize(Connection::class); $noDbNameConn->createSchemaManager()->willReturn($this->schemaManager->reveal()); @@ -66,7 +71,7 @@ class CreateDatabaseCommandTest extends TestCase public function successMessageIsPrintedIfDatabaseAlreadyExists(): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); @@ -86,11 +91,11 @@ class CreateDatabaseCommandTest extends TestCase public function databaseIsCreatedIfItDoesNotExist(): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); - $listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table']); + $listTables = $this->schemaManager->listTableNames()->willReturn(['foo_table', 'bar_table', MIGRATIONS_TABLE]); $this->commandTester->execute([]); @@ -100,15 +105,18 @@ class CreateDatabaseCommandTest extends TestCase $listTables->shouldHaveBeenCalledOnce(); } - /** @test */ - public function tablesAreCreatedIfDatabaseIsEmpty(): void + /** + * @test + * @dataProvider provideEmptyDatabase + */ + public function tablesAreCreatedIfDatabaseIsEmpty(array $tables): void { $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', $shlinkDatabase, 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); - $listTables = $this->schemaManager->listTableNames()->willReturn([]); + $listTables = $this->schemaManager->listTableNames()->willReturn($tables); $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [ '/usr/local/bin/php', CreateDatabaseCommand::DOCTRINE_SCRIPT, @@ -128,13 +136,19 @@ class CreateDatabaseCommandTest extends TestCase $runCommand->shouldHaveBeenCalledOnce(); } + public function provideEmptyDatabase(): iterable + { + yield 'no tables' => [[]]; + yield 'migrations table' => [[MIGRATIONS_TABLE]]; + } + /** @test */ public function databaseCheckIsSkippedForSqlite(): void { - $this->databasePlatform->getName()->willReturn('sqlite'); + $this->driver->getDatabasePlatform()->willReturn($this->prophesize(SqlitePlatform::class)->reveal()); $shlinkDatabase = 'shlink_database'; - $getDatabase = $this->regularConn->getDatabase()->willReturn($shlinkDatabase); + $getDatabase = $this->regularConn->getParams()->willReturn(['dbname' => $shlinkDatabase]); $listDatabases = $this->schemaManager->listDatabases()->willReturn(['foo', 'bar']); $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void { }); diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php new file mode 100644 index 00000000..f94a2000 --- /dev/null +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -0,0 +1,71 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + + $this->commandTester = $this->testerForCommand( + new GetDomainVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + ); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $shortUrl = ShortUrl::createEmpty(); + $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $domain = 'doma.in'; + $getVisits = $this->visitsHelper->visitsForDomain($domain, Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + + $this->commandTester->execute(['domain' => $domain]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + +---------+---------------------------+------------+---------+--------+---------------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 3ec90412..73d2b785 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; @@ -38,8 +39,7 @@ class CreateShortUrlCommandTest extends TestCase $command = new CreateShortUrlCommand( $this->urlShortener->reveal(), $this->stringifier->reveal(), - 5, - self::DEFAULT_DOMAIN, + new UrlShortenerOptions(['defaultShortCodesLength' => 5, 'domain' => ['hostname' => self::DEFAULT_DOMAIN]]), ); $this->commandTester = $this->testerForCommand($command); } diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 10a363c7..947b7443 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -36,10 +36,11 @@ class DeleteShortUrlCommandTest extends TestCase public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void { $shortCode = 'abc123'; - $deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->will( - function (): void { - }, - ); + $deleteByShortCode = $this->service->deleteByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + false, + )->will(function (): void { + }); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); @@ -55,7 +56,7 @@ class DeleteShortUrlCommandTest extends TestCase public function invalidShortCodePrintsMessage(): void { $shortCode = 'abc123'; - $identifier = new ShortUrlIdentifier($shortCode); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow( Exception\ShortUrlNotFoundException::fromNotFound($identifier), ); @@ -77,7 +78,7 @@ class DeleteShortUrlCommandTest extends TestCase string $expectedMessage, ): void { $shortCode = 'abc123'; - $identifier = new ShortUrlIdentifier($shortCode); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will( function (array $args) use ($shortCode): void { $ignoreThreshold = array_pop($args); @@ -114,12 +115,13 @@ class DeleteShortUrlCommandTest extends TestCase public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void { $shortCode = 'abc123'; - $deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow( - Exception\DeleteShortUrlException::fromVisitsThreshold( - 10, - ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - ), - ); + $deleteByShortCode = $this->service->deleteByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + false, + )->willThrow(Exception\DeleteShortUrlException::fromVisitsThreshold( + 10, + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + )); $this->commandTester->setInputs(['no']); $this->commandTester->execute(['shortCode' => $shortCode]); diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php similarity index 64% rename from module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php rename to module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index ca9e0981..316c762e 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -9,7 +9,7 @@ use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand; +use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -23,9 +23,10 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; +use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; -class GetVisitsCommandTest extends TestCase +class GetShortUrlVisitsCommandTest extends TestCase { use CliTestUtilsTrait; @@ -35,7 +36,7 @@ class GetVisitsCommandTest extends TestCase public function setUp(): void { $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $command = new GetVisitsCommand($this->visitsHelper->reveal()); + $command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal()); $this->commandTester = $this->testerForCommand($command); } @@ -44,8 +45,8 @@ class GetVisitsCommandTest extends TestCase { $shortCode = 'abc123'; $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), - new VisitsParams(DateRange::emptyInstance()), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsParams(DateRange::allTime()), ) ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); @@ -60,8 +61,8 @@ class GetVisitsCommandTest extends TestCase $startDate = '2016-01-01'; $endDate = '2016-02-01'; $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), - new VisitsParams(DateRange::withStartAndEndDate(Chronos::parse($startDate), Chronos::parse($endDate))), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsParams(buildDateRange(Chronos::parse($startDate), Chronos::parse($endDate))), ) ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); @@ -79,8 +80,8 @@ class GetVisitsCommandTest extends TestCase $shortCode = 'abc123'; $startDate = 'foo'; $info = $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), - new VisitsParams(DateRange::emptyInstance()), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsParams(DateRange::allTime()), )->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute([ @@ -99,19 +100,30 @@ class GetVisitsCommandTest extends TestCase /** @test */ public function outputIsProperlyGenerated(): void { + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( - new Paginator(new ArrayAdapter([ - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( - VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')), - ), - ])), + $this->visitsHelper->visitsForShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + Argument::any(), + )->willReturn( + new Paginator(new ArrayAdapter([$visit])), )->shouldBeCalledOnce(); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - self::assertStringContainsString('foo', $output); - self::assertStringContainsString('Spain', $output); - self::assertStringContainsString('bar', $output); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | + +---------+---------------------------+------------+---------+--------+ + + OUTPUT, + $output, + ); } } diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 38d3bcd3..f9d701cb 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -205,23 +206,23 @@ class ListShortUrlsCommandTest extends TestCase public function provideArgs(): iterable { - yield [[], 1, null, [], ShortUrlsParams::TAGS_MODE_ANY]; - yield [['--page' => $page = 3], $page, null, [], ShortUrlsParams::TAGS_MODE_ANY]; - yield [['--including-all-tags' => true], 1, null, [], ShortUrlsParams::TAGS_MODE_ALL]; - yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], ShortUrlsParams::TAGS_MODE_ANY]; + yield [[], 1, null, [], TagsMode::ANY->value]; + yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value]; + yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value]; + yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value]; yield [ ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'], $page, $searchTerm, explode(',', $tags), - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, ]; yield [ ['--start-date' => $startDate = '2019-01-01'], 1, null, [], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, $startDate, ]; yield [ @@ -229,7 +230,7 @@ class ListShortUrlsCommandTest extends TestCase 1, null, [], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, null, $endDate, ]; @@ -238,7 +239,7 @@ class ListShortUrlsCommandTest extends TestCase 1, null, [], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, $startDate, $endDate, ]; @@ -276,7 +277,7 @@ class ListShortUrlsCommandTest extends TestCase 'page' => 1, 'searchTerm' => null, 'tags' => [], - 'tagsMode' => ShortUrlsParams::TAGS_MODE_ANY, + 'tagsMode' => TagsMode::ANY->value, 'startDate' => null, 'endDate' => null, 'orderBy' => null, diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 2a816207..12e29eaf 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -37,8 +37,9 @@ class ResolveUrlCommandTest extends TestCase $shortCode = 'abc123'; $expectedUrl = 'http://domain.com/foo/bar'; $shortUrl = ShortUrl::withLongUrl($expectedUrl); - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl) - ->shouldBeCalledOnce(); + $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode))->willReturn( + $shortUrl, + )->shouldBeCalledOnce(); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); @@ -48,8 +49,8 @@ class ResolveUrlCommandTest extends TestCase /** @test */ public function incorrectShortCodeOutputsErrorMessage(): void { - $identifier = new ShortUrlIdentifier('abc123'); - $shortCode = $identifier->shortCode(); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123'); + $shortCode = $identifier->shortCode; $this->urlResolver->resolveShortUrl($identifier) ->willThrow(ShortUrlNotFoundException::fromNotFound($identifier)) diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php new file mode 100644 index 00000000..95036a7f --- /dev/null +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -0,0 +1,71 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + + $this->commandTester = $this->testerForCommand( + new GetTagVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + ); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $shortUrl = ShortUrl::createEmpty(); + $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $tag = 'abc123'; + $getVisits = $this->visitsHelper->visitsForTag($tag, Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + + $this->commandTester->execute(['tag' => $tag]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + +---------+---------------------------+------------+---------+--------+---------------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php new file mode 100644 index 00000000..d6888bf5 --- /dev/null +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -0,0 +1,70 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + + $this->commandTester = $this->testerForCommand( + new GetNonOrphanVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + ); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $shortUrl = ShortUrl::createEmpty(); + $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $getVisits = $this->visitsHelper->nonOrphanVisits(Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + +---------+---------------------------+------------+---------+--------+---------------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php new file mode 100644 index 00000000..c8c10aad --- /dev/null +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -0,0 +1,60 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper->reveal())); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $getVisits = $this->visitsHelper->orphanVisits(Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | base_url | + +---------+---------------------------+------------+---------+--------+----------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Exception/InvalidRoleConfigExceptionTest.php b/module/CLI/test/Exception/InvalidRoleConfigExceptionTest.php new file mode 100644 index 00000000..99c66ea4 --- /dev/null +++ b/module/CLI/test/Exception/InvalidRoleConfigExceptionTest.php @@ -0,0 +1,26 @@ +value, + ), $e->getMessage()); + } +} diff --git a/module/CLI/test/Util/GeolocationDbUpdaterTest.php b/module/CLI/test/Util/GeolocationDbUpdaterTest.php index 83340fc5..c5e3bdb4 100644 --- a/module/CLI/test/Util/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/Util/GeolocationDbUpdaterTest.php @@ -30,6 +30,7 @@ class GeolocationDbUpdaterTest extends TestCase private ObjectProphecy $dbUpdater; private ObjectProphecy $geoLiteDbReader; private TrackingOptions $trackingOptions; + private ObjectProphecy $lock; public function setUp(): void { @@ -38,11 +39,11 @@ class GeolocationDbUpdaterTest extends TestCase $this->trackingOptions = new TrackingOptions(); $locker = $this->prophesize(Lock\LockFactory::class); - $lock = $this->prophesize(Lock\LockInterface::class); - $lock->acquire(true)->willReturn(true); - $lock->release()->will(function (): void { + $this->lock = $this->prophesize(Lock\LockInterface::class); + $this->lock->acquire(true)->willReturn(true); + $this->lock->release()->will(function (): void { }); - $locker->createLock(Argument::type('string'))->willReturn($lock->reveal()); + $locker->createLock(Argument::type('string'))->willReturn($this->lock->reveal()); $this->geolocationDbUpdater = new GeolocationDbUpdater( $this->dbUpdater->reveal(), @@ -75,6 +76,8 @@ class GeolocationDbUpdaterTest extends TestCase $fileExists->shouldHaveBeenCalledOnce(); $getMeta->shouldNotHaveBeenCalled(); $download->shouldHaveBeenCalledOnce(); + $this->lock->acquire(true)->shouldHaveBeenCalledOnce(); + $this->lock->release()->shouldHaveBeenCalledOnce(); } /** diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 516ad8a1..9edc5fc2 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -27,6 +27,7 @@ return [ Options\UrlShortenerOptions::class => ConfigAbstractFactory::class, Options\TrackingOptions::class => ConfigAbstractFactory::class, Options\QrCodeOptions::class => ConfigAbstractFactory::class, + Options\RabbitMqOptions::class => ConfigAbstractFactory::class, Options\WebhookOptions::class => ConfigAbstractFactory::class, Service\UrlShortener::class => ConfigAbstractFactory::class, @@ -63,7 +64,7 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, - Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, + EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class, Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, @@ -91,6 +92,7 @@ return [ Options\UrlShortenerOptions::class => ['config.url_shortener'], Options\TrackingOptions::class => ['config.tracking'], Options\QrCodeOptions::class => ['config.qr_codes'], + Options\RabbitMqOptions::class => ['config.rabbitmq'], Options\WebhookOptions::class => ['config.visits_webhooks'], Service\UrlShortener::class => [ @@ -98,6 +100,7 @@ return [ 'em', ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, Service\ShortUrl\ShortCodeUniquenessHelper::class, + EventDispatcherInterface::class, ], Visit\VisitsTracker::class => [ 'em', @@ -157,7 +160,7 @@ return [ Options\UrlShortenerOptions::class, ], - Mercure\MercureUpdatesGenerator::class => [ + EventDispatcher\PublishingUpdatesGenerator::class => [ ShortUrl\Transformer\ShortUrlDataTransformer::class, Visit\Transformer\OrphanVisitDataTransformer::class, ], diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php index 969bfd1d..147c37e7 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php @@ -6,9 +6,11 @@ namespace Shlinkio\Shlink\Core; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; +use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\ClassMetadata; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); @@ -61,10 +63,13 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->nullable() ->build(); - $builder->createField('type', Types::STRING) - ->columnName('type') - ->length(255) - ->build(); + (new FieldBuilder($builder, [ + 'fieldName' => 'type', + 'type' => Types::STRING, + 'enumType' => VisitType::class, + ]))->columnName('type') + ->length(255) + ->build(); $builder->createField('potentialBot', Types::BOOLEAN) ->columnName('potential_bot') diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index d47cc128..467f63cc 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -5,12 +5,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use PhpAmqpLib\Connection\AMQPStreamConnection; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; +use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper; +use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; +use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -use Symfony\Component\Mercure\Hub; return [ @@ -22,11 +23,17 @@ return [ ], 'async' => [ EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\NotifyVisitToMercure::class, - EventDispatcher\NotifyVisitToRabbitMq::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, + ], ], ], @@ -34,16 +41,32 @@ return [ 'factories' => [ EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToRabbitMq::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, ], 'delegators' => [ - EventDispatcher\NotifyVisitToMercure::class => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], - EventDispatcher\NotifyVisitToRabbitMq::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\NotifyVisitToWebHooks::class => [ @@ -68,18 +91,46 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class, Options\AppOptions::class, ], - EventDispatcher\NotifyVisitToMercure::class => [ - Hub::class, - Mercure\MercureUpdatesGenerator::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, 'em', 'Logger_Shlink', ], - EventDispatcher\NotifyVisitToRabbitMq::class => [ - AMQPStreamConnection::class, + 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, - 'config.rabbitmq.enabled', + 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'], ], diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php deleted file mode 100644 index 07e33c73..00000000 --- a/module/Core/config/routes.config.php +++ /dev/null @@ -1,48 +0,0 @@ - [ - [ - 'name' => Action\RobotsAction::class, - 'path' => '/robots.txt', - 'middleware' => [ - Action\RobotsAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - [ - 'name' => Action\RedirectAction::class, - 'path' => '/{shortCode}', - 'middleware' => [ - IpAddress::class, - Action\RedirectAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - [ - 'name' => Action\PixelAction::class, - 'path' => '/{shortCode}/track', - 'middleware' => [ - IpAddress::class, - Action\PixelAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - [ - 'name' => Action\QrCodeAction::class, - 'path' => '/{shortCode}/qr-code', - 'middleware' => [ - Action\QrCodeAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - ], - -]; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 567fde47..c5186e41 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -8,16 +8,19 @@ use Cake\Chronos\Chronos; use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Jaybizzle\CrawlerDetect\CrawlerDetect; +use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; +use function date_default_timezone_get; use function Functional\reduce_left; use function is_array; use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; use function str_repeat; +use function ucfirst; function generateRandomShortCode(int $length): string { @@ -32,7 +35,7 @@ function generateRandomShortCode(int $length): string function parseDateFromQuery(array $query, string $dateName): ?Chronos { - return empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]); + return normalizeDate(empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName])); } function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange @@ -43,29 +46,15 @@ function parseDateRangeFromQuery(array $query, string $startDateName, string $en return buildDateRange($startDate, $endDate); } -function parseDateField(string|DateTimeInterface|Chronos|null $date): ?Chronos +function normalizeDate(string|DateTimeInterface|Chronos|null $date): ?Chronos { - if ($date === null || $date instanceof Chronos) { - return $date; - } + $parsedDate = match (true) { + $date === null || $date instanceof Chronos => $date, + $date instanceof DateTimeInterface => Chronos::instance($date), + default => Chronos::parse($date), + }; - if ($date instanceof DateTimeInterface) { - return Chronos::instance($date); - } - - return Chronos::parse($date); -} - -function determineTableName(string $tableName, array $emConfig = []): string -{ - $schema = $emConfig['connection']['schema'] ?? null; -// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO - - if ($schema === null) { - return $tableName; - } - - return sprintf('%s.%s', $schema, $tableName); + return $parsedDate?->setTimezone(date_default_timezone_get()); } function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int @@ -108,6 +97,18 @@ function isCrawler(string $userAgent): bool return $detector->isCrawler($userAgent); } +function determineTableName(string $tableName, array $emConfig = []): string +{ + $schema = $emConfig['connection']['schema'] ?? null; +// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO + + if ($schema === null) { + return $tableName; + } + + return sprintf('%s.%s', $schema, $tableName); +} + function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $collation = 'unicode_ci'): FieldBuilder { return match ($emConfig['connection']['driver'] ?? null) { @@ -116,3 +117,13 @@ function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $coll default => $field, }; } + +function camelCaseToHumanFriendly(string $value): string +{ + static $filter; + if ($filter === null) { + $filter = new CamelCaseToSeparator(' '); + } + + return ucfirst($filter->filter($value)); +} diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 42d643d3..7c1f0e34 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -29,11 +29,11 @@ final class QrCodeParams private const SUPPORTED_FORMATS = ['png', 'svg']; private function __construct( - private int $size, - private int $margin, - private WriterInterface $writer, - private ErrorCorrectionLevelInterface $errorCorrectionLevel, - private RoundBlockSizeModeInterface $roundBlockSizeMode, + public readonly int $size, + public readonly int $margin, + public readonly WriterInterface $writer, + public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel, + public readonly RoundBlockSizeModeInterface $roundBlockSizeMode, ) { } @@ -105,29 +105,4 @@ final class QrCodeParams { return strtolower(trim($param)); } - - public function size(): int - { - return $this->size; - } - - public function margin(): int - { - return $this->margin; - } - - public function writer(): WriterInterface - { - return $this->writer; - } - - public function errorCorrectionLevel(): ErrorCorrectionLevelInterface - { - return $this->errorCorrectionLevel; - } - - public function roundBlockSizeMode(): RoundBlockSizeModeInterface - { - return $this->roundBlockSizeMode; - } } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 7772a5c8..17bdbdfd 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -42,11 +42,11 @@ class QrCodeAction implements MiddlewareInterface $params = QrCodeParams::fromRequest($request, $this->defaultOptions); $qrCodeBuilder = Builder::create() ->data($this->stringifier->stringify($shortUrl)) - ->size($params->size()) - ->margin($params->margin()) - ->writer($params->writer()) - ->errorCorrectionLevel($params->errorCorrectionLevel()) - ->roundBlockSizeMode($params->roundBlockSizeMode()); + ->size($params->size) + ->margin($params->margin) + ->writer($params->writer) + ->errorCorrectionLevel($params->errorCorrectionLevel) + ->roundBlockSizeMode($params->roundBlockSizeMode); return new QrCodeResponse($qrCodeBuilder->build()); } diff --git a/module/Core/src/Config/BasePathPrefixer.php b/module/Core/src/Config/BasePathPrefixer.php index 1ad4e23b..4a306287 100644 --- a/module/Core/src/Config/BasePathPrefixer.php +++ b/module/Core/src/Config/BasePathPrefixer.php @@ -13,7 +13,6 @@ class BasePathPrefixer public function __invoke(array $config): array { $basePath = $config['router']['base_path'] ?? ''; - $config['url_shortener']['domain']['hostname'] .= $basePath; foreach (self::ELEMENTS_WITH_PATH as $configKey) { $config[$configKey] = $this->prefixPathsWithBasePath($configKey, $config, $basePath); diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 3513687d..ae93e4da 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -4,155 +4,84 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; -use ReflectionClass; -use ReflectionClassConstant; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; - -use function array_values; -use function Functional\contains; +use function Functional\map; use function Shlinkio\Shlink\Config\env; -// TODO Convert to enum - -/** - * @method static EnvVars DELETE_SHORT_URL_THRESHOLD() - * @method static EnvVars DB_DRIVER() - * @method static EnvVars DB_NAME() - * @method static EnvVars DB_USER() - * @method static EnvVars DB_PASSWORD() - * @method static EnvVars DB_HOST() - * @method static EnvVars DB_UNIX_SOCKET() - * @method static EnvVars DB_PORT() - * @method static EnvVars GEOLITE_LICENSE_KEY() - * @method static EnvVars REDIS_SERVERS() - * @method static EnvVars REDIS_SENTINEL_SERVICE() - * @method static EnvVars MERCURE_PUBLIC_HUB_URL() - * @method static EnvVars MERCURE_INTERNAL_HUB_URL() - * @method static EnvVars MERCURE_JWT_SECRET() - * @method static EnvVars DEFAULT_QR_CODE_SIZE() - * @method static EnvVars DEFAULT_QR_CODE_MARGIN() - * @method static EnvVars DEFAULT_QR_CODE_FORMAT() - * @method static EnvVars DEFAULT_QR_CODE_ERROR_CORRECTION() - * @method static EnvVars DEFAULT_QR_CODE_ROUND_BLOCK_SIZE() - * @method static EnvVars RABBITMQ_ENABLED() - * @method static EnvVars RABBITMQ_HOST() - * @method static EnvVars RABBITMQ_PORT() - * @method static EnvVars RABBITMQ_USER() - * @method static EnvVars RABBITMQ_PASSWORD() - * @method static EnvVars RABBITMQ_VHOST() - * @method static EnvVars DEFAULT_INVALID_SHORT_URL_REDIRECT() - * @method static EnvVars DEFAULT_REGULAR_404_REDIRECT() - * @method static EnvVars DEFAULT_BASE_URL_REDIRECT() - * @method static EnvVars REDIRECT_STATUS_CODE() - * @method static EnvVars REDIRECT_CACHE_LIFETIME() - * @method static EnvVars BASE_PATH() - * @method static EnvVars PORT() - * @method static EnvVars TASK_WORKER_NUM() - * @method static EnvVars WEB_WORKER_NUM() - * @method static EnvVars ANONYMIZE_REMOTE_ADDR() - * @method static EnvVars TRACK_ORPHAN_VISITS() - * @method static EnvVars DISABLE_TRACK_PARAM() - * @method static EnvVars DISABLE_TRACKING() - * @method static EnvVars DISABLE_IP_TRACKING() - * @method static EnvVars DISABLE_REFERRER_TRACKING() - * @method static EnvVars DISABLE_UA_TRACKING() - * @method static EnvVars DISABLE_TRACKING_FROM() - * @method static EnvVars DEFAULT_SHORT_CODES_LENGTH() - * @method static EnvVars IS_HTTPS_ENABLED() - * @method static EnvVars DEFAULT_DOMAIN() - * @method static EnvVars AUTO_RESOLVE_TITLES() - * @method static EnvVars REDIRECT_APPEND_EXTRA_PATH() - * @method static EnvVars VISITS_WEBHOOKS() - * @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS() - */ -final class EnvVars +enum EnvVars: string { - public const DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD'; - public const DB_DRIVER = 'DB_DRIVER'; - public const DB_NAME = 'DB_NAME'; - public const DB_USER = 'DB_USER'; - public const DB_PASSWORD = 'DB_PASSWORD'; - public const DB_HOST = 'DB_HOST'; - public const DB_UNIX_SOCKET = 'DB_UNIX_SOCKET'; - public const DB_PORT = 'DB_PORT'; - public const GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY'; - public const REDIS_SERVERS = 'REDIS_SERVERS'; - public const REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; - public const MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL'; - public const MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL'; - public const MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET'; - public const DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; - public const DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; - public const DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; - public const DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; - public const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; - public const RABBITMQ_ENABLED = 'RABBITMQ_ENABLED'; - public const RABBITMQ_HOST = 'RABBITMQ_HOST'; - public const RABBITMQ_PORT = 'RABBITMQ_PORT'; - public const RABBITMQ_USER = 'RABBITMQ_USER'; - public const RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD'; - public const RABBITMQ_VHOST = 'RABBITMQ_VHOST'; - public const DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; - public const DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; - public const DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; - public const REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE'; - public const REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; - public const BASE_PATH = 'BASE_PATH'; - public const PORT = 'PORT'; - public const TASK_WORKER_NUM = 'TASK_WORKER_NUM'; - public const WEB_WORKER_NUM = 'WEB_WORKER_NUM'; - public const ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR'; - public const TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS'; - public const DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM'; - public const DISABLE_TRACKING = 'DISABLE_TRACKING'; - public const DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING'; - public const DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING'; - public const DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING'; - public const DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM'; - public const DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH'; - public const IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED'; - public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN'; - public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; - public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; + case DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD'; + case DB_DRIVER = 'DB_DRIVER'; + case DB_NAME = 'DB_NAME'; + case DB_USER = 'DB_USER'; + case DB_PASSWORD = 'DB_PASSWORD'; + case DB_HOST = 'DB_HOST'; + case DB_UNIX_SOCKET = 'DB_UNIX_SOCKET'; + case DB_PORT = 'DB_PORT'; + case GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY'; + case REDIS_SERVERS = 'REDIS_SERVERS'; + case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; + 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'; /** @deprecated */ - public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; + case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING'; + 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'; + case REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE'; + case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; + case BASE_PATH = 'BASE_PATH'; + case PORT = 'PORT'; + case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; + case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; + case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR'; + case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS'; + case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM'; + case DISABLE_TRACKING = 'DISABLE_TRACKING'; + case DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING'; + case DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING'; + case DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING'; + case DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM'; + case DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH'; + case IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED'; + case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN'; + case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; + case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; + case TIMEZONE = 'TIMEZONE'; + case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED'; /** @deprecated */ - public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; - - /** - * @return string[] - */ - public static function cases(): array - { - static $constants; - if ($constants !== null) { - return $constants; - } - - $ref = new ReflectionClass(self::class); - return $constants = array_values($ref->getConstants(ReflectionClassConstant::IS_PUBLIC)); - } - - private function __construct(private string $envVar) - { - } - - public static function __callStatic(string $name, array $arguments): self - { - if (! contains(self::cases(), $name)) { - throw new InvalidArgumentException('Invalid env var: "' . $name . '"'); - } - - return new self($name); - } + case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; + /** @deprecated */ + case NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; public function loadFromEnv(mixed $default = null): mixed { - return env($this->envVar, $default); + return env($this->value, $default); } public function existsInEnv(): bool { return $this->loadFromEnv() !== null; } + + /** + * @return string[] + */ + public static function values(): array + { + static $values; + return $values ?? ($values = map(self::cases(), static fn (EnvVars $envVar) => $envVar->value)); + } } diff --git a/module/Core/src/Config/MultiSegmentSlugProcessor.php b/module/Core/src/Config/MultiSegmentSlugProcessor.php new file mode 100644 index 00000000..b9cf2457 --- /dev/null +++ b/module/Core/src/Config/MultiSegmentSlugProcessor.php @@ -0,0 +1,30 @@ + $path] = $route; + $route['path'] = str_replace(self::SINGLE_SHORT_CODE_PATTERN, self::MULTI_SHORT_CODE_PATTERN, $path); + return $route; + }); + + return $config; + } +} diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index caa100c3..3ab2e740 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use function Functional\compose; +use function Functional\id; use function str_replace; +use function urlencode; class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface { @@ -71,10 +73,10 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface $replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier), ); $replacePlaceholdersInPath = compose( - $replacePlaceholders('\Functional\id'), - static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), // Fix duplicated bars + $replacePlaceholders(id(...)), + static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), ); - $replacePlaceholdersInQuery = $replacePlaceholders('\urlencode'); + $replacePlaceholdersInQuery = $replacePlaceholders(urlencode(...)); return $redirectUri ->withPath($replacePlaceholdersInPath($redirectUri->getPath())) diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index 492a00bc..48437924 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -9,9 +9,9 @@ use JsonSerializable; final class NotFoundRedirects implements JsonSerializable { private function __construct( - private ?string $baseUrlRedirect, - private ?string $regular404Redirect, - private ?string $invalidShortUrlRedirect, + public readonly ?string $baseUrlRedirect, + public readonly ?string $regular404Redirect, + public readonly ?string $invalidShortUrlRedirect, ) { } @@ -33,21 +33,6 @@ final class NotFoundRedirects implements JsonSerializable return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect()); } - public function baseUrlRedirect(): ?string - { - return $this->baseUrlRedirect; - } - - public function regular404Redirect(): ?string - { - return $this->regular404Redirect; - } - - public function invalidShortUrlRedirect(): ?string - { - return $this->invalidShortUrlRedirect; - } - public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 5547fe8d..cc968e95 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -12,9 +12,9 @@ use Shlinkio\Shlink\Core\Entity\Domain; final class DomainItem implements JsonSerializable { private function __construct( - private string $authority, - private NotFoundRedirectConfigInterface $notFoundRedirectConfig, - private bool $isDefault, + private readonly string $authority, + public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig, + public readonly bool $isDefault, ) { } @@ -23,9 +23,9 @@ final class DomainItem implements JsonSerializable return new self($domain->getAuthority(), $domain, false); } - public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self + public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self { - return new self($authority, $config, true); + return new self($defaultDomain, $config, true); } public function jsonSerialize(): array @@ -41,14 +41,4 @@ final class DomainItem implements JsonSerializable { return $this->authority; } - - public function isDefault(): bool - { - return $this->isDefault; - } - - public function notFoundRedirectConfig(): NotFoundRedirectConfigInterface - { - return $this->notFoundRedirectConfig; - } } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 4de3ea36..60c32499 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\ORM\Query\Expr\Join; +use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; -use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Domain\Spec\IsDomain; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -40,8 +40,25 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain { - $qb = $this->createQueryBuilder('d'); - $qb->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') + $qb = $this->createDomainQueryBuilder($authority, $apiKey); + $qb->select('d'); + + return $qb->getQuery()->getOneOrNullResult(); + } + + public function domainExists(string $authority, ?ApiKey $apiKey = null): bool + { + $qb = $this->createDomainQueryBuilder($authority, $apiKey); + $qb->select('COUNT(d.id)'); + + return ((int) $qb->getQuery()->getSingleScalarResult()) > 0; + } + + private function createDomainQueryBuilder(string $authority, ?ApiKey $apiKey): QueryBuilder + { + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(Domain::class, 'd') + ->leftJoin(ShortUrl::class, 's', Join::WITH, 's.domain = d') ->where($qb->expr()->eq('d.authority', ':authority')) ->setParameter('authority', $authority) ->setMaxResults(1); @@ -51,7 +68,7 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe $this->applySpecification($qb, $spec, $alias); } - return $qb->getQuery()->getOneOrNullResult(); + return $qb; } private function determineExtraSpecs(?ApiKey $apiKey): iterable @@ -59,10 +76,9 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe // FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the // ShortUrl is the root entity. Here, the Domain is the root entity. // Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible. - yield from $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) { + 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, Spec::andX()], }) ?? []; } } diff --git a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php index 69e74e5b..d5f880bd 100644 --- a/module/Core/src/Domain/Repository/DomainRepositoryInterface.php +++ b/module/Core/src/Domain/Repository/DomainRepositoryInterface.php @@ -17,4 +17,6 @@ interface DomainRepositoryInterface extends ObjectRepository, EntitySpecificatio public function findDomains(?ApiKey $apiKey = null): array; public function findOneByAuthority(string $authority, ?ApiKey $apiKey = null): ?Domain; + + public function domainExists(string $authority, ?ApiKey $apiKey = null): bool; } diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index 65ca8ce6..9c31bbe2 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -66,8 +66,8 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec public function configureNotFoundRedirects(NotFoundRedirects $redirects): void { - $this->baseUrlRedirect = $redirects->baseUrlRedirect(); - $this->regular404Redirect = $redirects->regular404Redirect(); - $this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect(); + $this->baseUrlRedirect = $redirects->baseUrlRedirect; + $this->regular404Redirect = $redirects->regular404Redirect; + $this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect; } } diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 9fff1509..6a146372 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -93,31 +94,31 @@ class ShortUrl extends AbstractEntity ): self { $meta = [ ShortUrlInputFilter::VALIDATE_URL => false, - ShortUrlInputFilter::LONG_URL => $url->longUrl(), - ShortUrlInputFilter::DOMAIN => $url->domain(), - ShortUrlInputFilter::TAGS => $url->tags(), - ShortUrlInputFilter::TITLE => $url->title(), - ShortUrlInputFilter::MAX_VISITS => $url->meta()->maxVisits(), + ShortUrlInputFilter::LONG_URL => $url->longUrl, + ShortUrlInputFilter::DOMAIN => $url->domain, + ShortUrlInputFilter::TAGS => $url->tags, + ShortUrlInputFilter::TITLE => $url->title, + ShortUrlInputFilter::MAX_VISITS => $url->meta->maxVisits, ]; if ($importShortCode) { - $meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode(); + $meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode; } $instance = self::fromMeta(ShortUrlMeta::fromRawData($meta), $relationResolver); - $validSince = $url->meta()->validSince(); + $validSince = $url->meta->validSince; if ($validSince !== null) { $instance->validSince = Chronos::instance($validSince); } - $validUntil = $url->meta()->validUntil(); + $validUntil = $url->meta->validUntil; if ($validUntil !== null) { $instance->validUntil = Chronos::instance($validUntil); } - $instance->importSource = $url->source(); - $instance->importOriginalShortCode = $url->shortCode(); - $instance->dateCreated = Chronos::instance($url->createdAt()); + $instance->importSource = $url->source->value; + $instance->importOriginalShortCode = $url->shortCode; + $instance->dateCreated = Chronos::instance($url->createdAt); return $instance; } @@ -174,7 +175,7 @@ class ShortUrl extends AbstractEntity { /** @var Selectable $visits */ $visits = $this->visits; - $criteria = Criteria::create()->where(Criteria::expr()->eq('type', Visit::TYPE_IMPORTED)) + $criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED)) ->orderBy(['id' => 'DESC']) ->setMaxResults(1); diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index c509bcc3..fd53ab9b 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -10,30 +10,24 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; use function Shlinkio\Shlink\Core\isCrawler; class Visit extends AbstractEntity implements JsonSerializable { - public const TYPE_VALID_SHORT_URL = 'valid_short_url'; - public const TYPE_IMPORTED = 'imported'; - public const TYPE_INVALID_SHORT_URL = 'invalid_short_url'; - public const TYPE_BASE_URL = 'base_url'; - public const TYPE_REGULAR_404 = 'regular_404'; - private string $referer; private Chronos $date; private ?string $remoteAddr = null; private ?string $visitedUrl = null; private string $userAgent; - private string $type; + private VisitType $type; private ?ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; private bool $potentialBot; - private function __construct(?ShortUrl $shortUrl, string $type) + private function __construct(?ShortUrl $shortUrl, VisitType $type) { $this->shortUrl = $shortUrl; $this->date = Chronos::now(); @@ -42,7 +36,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self { - $instance = new self($shortUrl, self::TYPE_VALID_SHORT_URL); + $instance = new self($shortUrl, VisitType::VALID_SHORT_URL); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -50,13 +44,13 @@ class Visit extends AbstractEntity implements JsonSerializable public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self { - $instance = new self($shortUrl, self::TYPE_IMPORTED); - $instance->userAgent = $importedVisit->userAgent(); + $instance = new self($shortUrl, VisitType::IMPORTED); + $instance->userAgent = $importedVisit->userAgent; $instance->potentialBot = isCrawler($instance->userAgent); - $instance->referer = $importedVisit->referer(); - $instance->date = Chronos::instance($importedVisit->date()); + $instance->referer = $importedVisit->referer; + $instance->date = Chronos::instance($importedVisit->date); - $importedLocation = $importedVisit->location(); + $importedLocation = $importedVisit->location; $instance->visitLocation = $importedLocation !== null ? VisitLocation::fromImport($importedLocation) : null; return $instance; @@ -64,7 +58,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forBasePath(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, self::TYPE_BASE_URL); + $instance = new self(null, VisitType::BASE_URL); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -72,7 +66,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, self::TYPE_INVALID_SHORT_URL); + $instance = new self(null, VisitType::INVALID_SHORT_URL); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -80,7 +74,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, self::TYPE_REGULAR_404); + $instance = new self(null, VisitType::REGULAR_404); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -88,10 +82,10 @@ class Visit extends AbstractEntity implements JsonSerializable private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void { - $this->userAgent = $visitor->getUserAgent(); - $this->referer = $visitor->getReferer(); - $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); - $this->visitedUrl = $visitor->getVisitedUrl(); + $this->userAgent = $visitor->userAgent; + $this->referer = $visitor->referer; + $this->remoteAddr = $this->processAddress($anonymize, $visitor->remoteAddress); + $this->visitedUrl = $visitor->visitedUrl; $this->potentialBot = $visitor->isPotentialBot(); } @@ -124,7 +118,7 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->shortUrl; } - public function getVisitLocation(): ?VisitLocationInterface + public function getVisitLocation(): ?VisitLocation { return $this->visitLocation; } @@ -150,7 +144,7 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->visitedUrl; } - public function type(): string + public function type(): VisitType { return $this->type; } @@ -159,11 +153,19 @@ class Visit extends AbstractEntity implements JsonSerializable * Needed only for ArrayCollections to be able to apply criteria filtering * @internal */ - public function getType(): string + public function getType(): VisitType { return $this->type(); } + /** + * @internal + */ + public function getDate(): Chronos + { + return $this->date; + } + public function jsonSerialize(): array { return [ @@ -174,12 +176,4 @@ class Visit extends AbstractEntity implements JsonSerializable 'potentialBot' => $this->potentialBot, ]; } - - /** - * @internal - */ - public function getDate(): Chronos - { - return $this->date; - } } diff --git a/module/Core/src/Entity/VisitLocation.php b/module/Core/src/Entity/VisitLocation.php index 594126a7..bcd8e95d 100644 --- a/module/Core/src/Entity/VisitLocation.php +++ b/module/Core/src/Entity/VisitLocation.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Entity; +use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; -use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation; use Shlinkio\Shlink\IpGeolocation\Model\Location; -class VisitLocation extends AbstractEntity implements VisitLocationInterface +class VisitLocation extends AbstractEntity implements JsonSerializable { private string $countryCode; private string $countryName; @@ -28,13 +28,13 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface { $instance = new self(); - $instance->countryCode = $location->countryCode(); - $instance->countryName = $location->countryName(); - $instance->regionName = $location->regionName(); - $instance->cityName = $location->city(); - $instance->latitude = $location->latitude(); - $instance->longitude = $location->longitude(); - $instance->timezone = $location->timeZone(); + $instance->countryCode = $location->countryCode; + $instance->countryName = $location->countryName; + $instance->regionName = $location->regionName; + $instance->cityName = $location->city; + $instance->latitude = $location->latitude; + $instance->longitude = $location->longitude; + $instance->timezone = $location->timeZone; $instance->computeIsEmpty(); return $instance; @@ -44,13 +44,13 @@ class VisitLocation extends AbstractEntity implements VisitLocationInterface { $instance = new self(); - $instance->countryCode = $location->countryCode(); - $instance->countryName = $location->countryName(); - $instance->regionName = $location->regionName(); - $instance->cityName = $location->cityName(); - $instance->latitude = $location->latitude(); - $instance->longitude = $location->longitude(); - $instance->timezone = $location->timeZone(); + $instance->countryCode = $location->countryCode; + $instance->countryName = $location->countryName; + $instance->regionName = $location->regionName; + $instance->cityName = $location->cityName; + $instance->latitude = $location->latitude; + $instance->longitude = $location->longitude; + $instance->timezone = $location->timezone; $instance->computeIsEmpty(); return $instance; diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index 39970dea..99f7fbe6 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -7,27 +7,27 @@ namespace Shlinkio\Shlink\Core\ErrorHandler\Model; use Mezzio\Router\RouteResult; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; -use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use function rtrim; class NotFoundType { - private function __construct(private string $type) + private function __construct(private readonly ?VisitType $type) { } public static function fromRequest(ServerRequestInterface $request, string $basePath): self { /** @var RouteResult $routeResult */ - $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); + $routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null); $isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath; $type = match (true) { - $isBaseUrl => Visit::TYPE_BASE_URL, - $routeResult->isFailure() => Visit::TYPE_REGULAR_404, - $routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL, - default => self::class, + $isBaseUrl => VisitType::BASE_URL, + $routeResult->isFailure() => VisitType::REGULAR_404, + $routeResult->getMatchedRouteName() === RedirectAction::class => VisitType::INVALID_SHORT_URL, + default => null, }; return new self($type); @@ -35,16 +35,16 @@ class NotFoundType public function isBaseUrl(): bool { - return $this->type === Visit::TYPE_BASE_URL; + return $this->type === VisitType::BASE_URL; } public function isRegularNotFound(): bool { - return $this->type === Visit::TYPE_REGULAR_404; + return $this->type === VisitType::REGULAR_404; } public function isInvalidShortUrl(): bool { - return $this->type === Visit::TYPE_INVALID_SHORT_URL; + return $this->type === VisitType::INVALID_SHORT_URL; } } diff --git a/module/Core/src/EventDispatcher/Async/AbstractAsyncListener.php b/module/Core/src/EventDispatcher/Async/AbstractAsyncListener.php new file mode 100644 index 00000000..ae8391db --- /dev/null +++ b/module/Core/src/EventDispatcher/Async/AbstractAsyncListener.php @@ -0,0 +1,12 @@ +isEnabled()) { + return; + } + + $shortUrlId = $shortUrlCreated->shortUrlId; + $shortUrl = $this->em->find(ShortUrl::class, $shortUrlId); + $name = $this->getRemoteSystem()->value; + + if ($shortUrl === null) { + $this->logger->warning( + 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => $shortUrlId, 'name' => $name], + ); + return; + } + + try { + $this->publishingHelper->publishUpdate($this->updatesGenerator->newShortUrlUpdate($shortUrl)); + } catch (Throwable $e) { + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => $name], + ); + } + } +} diff --git a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php new file mode 100644 index 00000000..5852b032 --- /dev/null +++ b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php @@ -0,0 +1,72 @@ +isEnabled()) { + return; + } + + $visitId = $visitLocated->visitId; + $visit = $this->em->find(Visit::class, $visitId); + $name = $this->getRemoteSystem()->value; + + if ($visit === null) { + $this->logger->warning( + 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId, 'name' => $name], + ); + return; + } + + $updates = $this->determineUpdatesForVisit($visit); + + try { + each($updates, fn (Update $update) => $this->publishingHelper->publishUpdate($update)); + } catch (Throwable $e) { + $this->logger->debug( + 'Error while trying to notify {name} with new visit. {e}', + ['e' => $e, 'name' => $name], + ); + } + } + + /** + * @return Update[] + */ + protected function determineUpdatesForVisit(Visit $visit): array + { + if ($visit->isOrphan()) { + return [$this->updatesGenerator->newOrphanVisitUpdate($visit)]; + } + + return [ + $this->updatesGenerator->newShortUrlVisitUpdate($visit), + $this->updatesGenerator->newVisitUpdate($visit), + ]; + } +} diff --git a/module/Core/src/EventDispatcher/Async/RemoteSystem.php b/module/Core/src/EventDispatcher/Async/RemoteSystem.php new file mode 100644 index 00000000..2cdda1d9 --- /dev/null +++ b/module/Core/src/EventDispatcher/Async/RemoteSystem.php @@ -0,0 +1,12 @@ +visitId; - } - public function jsonSerialize(): array { return ['visitId' => $this->visitId]; diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php new file mode 100644 index 00000000..9786808f --- /dev/null +++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php @@ -0,0 +1,21 @@ + $this->shortUrlId, + ]; + } +} diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index 633b439e..02452a3e 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,13 +6,8 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; final class UrlVisited extends AbstractVisitEvent { - public function __construct(string $visitId, private ?string $originalIpAddress = null) + public function __construct(string $visitId, public readonly ?string $originalIpAddress = null) { parent::__construct($visitId); } - - public function originalIpAddress(): ?string - { - return $this->originalIpAddress; - } } diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index fbd32962..197ce9a0 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -30,7 +30,7 @@ class LocateVisit public function __invoke(UrlVisited $shortUrlVisited): void { - $visitId = $shortUrlVisited->visitId(); + $visitId = $shortUrlVisited->visitId; /** @var Visit|null $visit */ $visit = $this->em->find(Visit::class, $visitId); @@ -41,7 +41,7 @@ class LocateVisit return; } - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); $this->eventDispatcher->dispatch(new VisitLocated($visitId)); } diff --git a/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php b/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php new file mode 100644 index 00000000..cccee78d --- /dev/null +++ b/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php @@ -0,0 +1,21 @@ +visitId(); - - /** @var Visit|null $visit */ - $visit = $this->em->find(Visit::class, $visitId); - if ($visit === null) { - $this->logger->warning('Tried to notify mercure for visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - try { - each($this->determineUpdatesForVisit($visit), fn (Update $update) => $this->hub->publish($update)); - } catch (Throwable $e) { - $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ - 'e' => $e, - ]); - } - } - - /** - * @return Update[] - */ - private function determineUpdatesForVisit(Visit $visit): array - { - if ($visit->isOrphan()) { - return [$this->updatesGenerator->newOrphanVisitUpdate($visit)]; - } - - return [ - $this->updatesGenerator->newShortUrlVisitUpdate($visit), - $this->updatesGenerator->newVisitUpdate($visit), - ]; - } -} diff --git a/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php deleted file mode 100644 index f05ecf64..00000000 --- a/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php +++ /dev/null @@ -1,102 +0,0 @@ -isEnabled) { - return; - } - - $visitId = $shortUrlLocated->visitId(); - $visit = $this->em->find(Visit::class, $visitId); - - if ($visit === null) { - $this->logger->warning('Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - if (! $this->connection->isConnected()) { - $this->connection->reconnect(); - } - - $queues = $this->determineQueuesToPublishTo($visit); - $message = $this->visitToMessage($visit); - - try { - $channel = $this->connection->channel(); - - foreach ($queues as $queue) { - // Declare an exchange and a queue that will persist server restarts - $exchange = $queue; // We use the same name for the exchange and the queue - $channel->exchange_declare($exchange, AMQPExchangeType::DIRECT, false, true, false); - $channel->queue_declare($queue, false, true, false, false); - - // Bind the exchange and the queue together, and publish the message - $channel->queue_bind($queue, $exchange); - $channel->basic_publish($message, $exchange); - } - - $channel->close(); - } catch (Throwable $e) { - $this->logger->debug('Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e]); - } finally { - $this->connection->close(); - } - } - - /** - * @return string[] - */ - private function determineQueuesToPublishTo(Visit $visit): array - { - if ($visit->isOrphan()) { - return [self::NEW_ORPHAN_VISIT_QUEUE]; - } - - return [ - self::NEW_VISIT_QUEUE, - sprintf('%s/%s', self::NEW_VISIT_QUEUE, $visit->getShortUrl()?->getShortCode()), - ]; - } - - private function visitToMessage(Visit $visit): AMQPMessage - { - $messageBody = json_encode(! $visit->isOrphan() ? $visit : $this->orphanVisitTransformer->transform($visit)); - return new AMQPMessage($messageBody, [ - 'content_type' => 'application/json', - 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, - ]); - } -} diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index 73ff9266..1bf09517 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -40,7 +40,7 @@ class NotifyVisitToWebHooks return; } - $visitId = $shortUrlLocated->visitId(); + $visitId = $shortUrlLocated->visitId; /** @var Visit|null $visit */ $visit = $this->em->find(Visit::class, $visitId); diff --git a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php new file mode 100644 index 00000000..0f7de480 --- /dev/null +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php @@ -0,0 +1,52 @@ +value, [ + 'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()), + 'visit' => $visit->jsonSerialize(), + ]); + } + + public function newOrphanVisitUpdate(Visit $visit): Update + { + return Update::forTopicAndPayload(Topic::NEW_ORPHAN_VISIT->value, [ + 'visit' => $this->orphanVisitTransformer->transform($visit), + ]); + } + + public function newShortUrlVisitUpdate(Visit $visit): Update + { + $shortUrl = $visit->getShortUrl(); + $topic = Topic::newShortUrlVisit($shortUrl?->getShortCode()); + + return Update::forTopicAndPayload($topic, [ + 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), + 'visit' => $visit->jsonSerialize(), + ]); + } + + public function newShortUrlUpdate(ShortUrl $shortUrl): Update + { + return Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, [ + 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), + ]); + } +} diff --git a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php b/module/Core/src/EventDispatcher/PublishingUpdatesGeneratorInterface.php similarity index 51% rename from module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php rename to module/Core/src/EventDispatcher/PublishingUpdatesGeneratorInterface.php index 951e805c..826157eb 100644 --- a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGeneratorInterface.php @@ -2,16 +2,19 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Mercure; +namespace Shlinkio\Shlink\Core\EventDispatcher; +use Shlinkio\Shlink\Common\UpdatePublishing\Update; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; -use Symfony\Component\Mercure\Update; -interface MercureUpdatesGeneratorInterface +interface PublishingUpdatesGeneratorInterface { public function newVisitUpdate(Visit $visit): Update; public function newOrphanVisitUpdate(Visit $visit): Update; public function newShortUrlVisitUpdate(Visit $visit): Update; + + public function newShortUrlUpdate(ShortUrl $shortUrl): Update; } diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php new file mode 100644 index 00000000..488247d7 --- /dev/null +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php @@ -0,0 +1,36 @@ +options->isEnabled(); + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::RABBIT_MQ; + } +} diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php new file mode 100644 index 00000000..0faa795c --- /dev/null +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php @@ -0,0 +1,71 @@ +options->legacyVisitsPublishing()) { + return parent::determineUpdatesForVisit($visit); + } + + // This was defined incorrectly. + // According to the spec, both the visit and the short URL it belongs to, should be published. + // The shape should be ['visit' => [...], 'shortUrl' => ?[...]] + // However, this would be a breaking change, so we need a flag that determines the shape of the payload. + return $visit->isOrphan() + ? [ + Update::forTopicAndPayload( + Topic::NEW_ORPHAN_VISIT->value, + $this->orphanVisitTransformer->transform($visit), + ), + ] + : [ + Update::forTopicAndPayload(Topic::NEW_VISIT->value, $visit->jsonSerialize()), + Update::forTopicAndPayload( + Topic::newShortUrlVisit($visit->getShortUrl()?->getShortCode()), + $visit->jsonSerialize(), + ), + ]; + } + + protected function isEnabled(): bool + { + return $this->options->isEnabled(); + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::RABBIT_MQ; + } +} diff --git a/module/Core/src/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedis.php b/module/Core/src/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedis.php new file mode 100644 index 00000000..5cee9d5e --- /dev/null +++ b/module/Core/src/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedis.php @@ -0,0 +1,35 @@ +enabled; + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::REDIS_PUB_SUB; + } +} diff --git a/module/Core/src/EventDispatcher/RedisPubSub/NotifyVisitToRedis.php b/module/Core/src/EventDispatcher/RedisPubSub/NotifyVisitToRedis.php new file mode 100644 index 00000000..ae349495 --- /dev/null +++ b/module/Core/src/EventDispatcher/RedisPubSub/NotifyVisitToRedis.php @@ -0,0 +1,35 @@ +enabled; + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::REDIS_PUB_SUB; + } +} diff --git a/module/Core/src/EventDispatcher/Topic.php b/module/Core/src/EventDispatcher/Topic.php new file mode 100644 index 00000000..0cba5a09 --- /dev/null +++ b/module/Core/src/EventDispatcher/Topic.php @@ -0,0 +1,19 @@ +value, $shortCode ?? ''); + } +} diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index e6f3bd0d..0d331400 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -20,8 +20,8 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self { - $shortCode = $identifier->shortCode(); - $domain = $identifier->domain(); + $shortCode = $identifier->shortCode; + $domain = $identifier->domain; $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf( 'Impossible to delete short URL with short code "%s"%s, since it has more than "%s" visits.', diff --git a/module/Core/src/Exception/NonUniqueSlugException.php b/module/Core/src/Exception/NonUniqueSlugException.php index 3b8b0b15..f61c480f 100644 --- a/module/Core/src/Exception/NonUniqueSlugException.php +++ b/module/Core/src/Exception/NonUniqueSlugException.php @@ -38,6 +38,6 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem public static function fromImport(ImportedShlinkUrl $importedUrl): self { - return self::fromSlug($importedUrl->shortCode(), $importedUrl->domain()); + return self::fromSlug($importedUrl->shortCode, $importedUrl->domain); } } diff --git a/module/Core/src/Exception/ShortUrlNotFoundException.php b/module/Core/src/Exception/ShortUrlNotFoundException.php index 0ae29da5..c59c20ef 100644 --- a/module/Core/src/Exception/ShortUrlNotFoundException.php +++ b/module/Core/src/Exception/ShortUrlNotFoundException.php @@ -20,8 +20,8 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail public static function fromNotFound(ShortUrlIdentifier $identifier): self { - $shortCode = $identifier->shortCode(); - $domain = $identifier->domain(); + $shortCode = $identifier->shortCode; + $domain = $identifier->domain; $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix)); diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php index fe4f24df..f5818252 100644 --- a/module/Core/src/Importer/ImportedLinksProcessor.php +++ b/module/Core/src/Importer/ImportedLinksProcessor.php @@ -13,8 +13,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; -use Shlinkio\Shlink\Importer\Sources\ImportSources; +use Shlinkio\Shlink\Importer\Params\ImportParams; +use Shlinkio\Shlink\Importer\Sources\ImportSource; +use Symfony\Component\Console\Style\OutputStyle; use Symfony\Component\Console\Style\StyleInterface; +use Throwable; use function sprintf; @@ -23,45 +26,49 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface private ShortUrlRepositoryInterface $shortUrlRepo; public function __construct( - private EntityManagerInterface $em, - private ShortUrlRelationResolverInterface $relationResolver, - private ShortCodeUniquenessHelperInterface $shortCodeHelper, - private DoctrineBatchHelperInterface $batchHelper, + private readonly EntityManagerInterface $em, + private readonly ShortUrlRelationResolverInterface $relationResolver, + private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, + private readonly DoctrineBatchHelperInterface $batchHelper, ) { $this->shortUrlRepo = $this->em->getRepository(ShortUrl::class); } /** - * @param iterable|ImportedShlinkUrl[] $shlinkUrls + * @param iterable $shlinkUrls */ - public function process(StyleInterface $io, iterable $shlinkUrls, array $params): void + public function process(StyleInterface $io, iterable $shlinkUrls, ImportParams $params): void { - $importShortCodes = $params['import_short_codes']; - $source = $params['source']; - $iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSources::SHLINK ? 10 : 100); + $importShortCodes = $params->importShortCodes; + $source = $params->source; + $iterable = $this->batchHelper->wrapIterable($shlinkUrls, $source === ImportSource::SHLINK ? 10 : 100); /** @var ImportedShlinkUrl $importedUrl */ foreach ($iterable as $importedUrl) { - $skipOnShortCodeConflict = static function () use ($io, $importedUrl): bool { - $action = $io->choice(sprintf( - 'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate ' - . 'a new one or skip it?', - $importedUrl->longUrl(), - $importedUrl->shortCode(), - ), ['Generate new short-code', 'Skip'], 1); - - return $action === 'Skip'; - }; - $longUrl = $importedUrl->longUrl(); + $skipOnShortCodeConflict = static fn (): bool => $io->choice(sprintf( + 'Failed to import URL "%s" because its short-code "%s" is already in use. Do you want to generate ' + . 'a new one or skip it?', + $importedUrl->longUrl, + $importedUrl->shortCode, + ), ['Generate new short-code', 'Skip'], 1) === 'Skip'; + $longUrl = $importedUrl->longUrl; try { $shortUrlImporting = $this->resolveShortUrl($importedUrl, $importShortCodes, $skipOnShortCodeConflict); } catch (NonUniqueSlugException) { $io->text(sprintf('%s: Error', $longUrl)); + continue; + } catch (Throwable $e) { + $io->text(sprintf('%s: Skipped. Reason: %s.', $longUrl, $e->getMessage())); + + if ($io instanceof OutputStyle && $io->isVerbose()) { + $io->text($e->__toString()); + } + continue; } - $resultMessage = $shortUrlImporting->importVisits($importedUrl->visits(), $this->em); + $resultMessage = $shortUrlImporting->importVisits($importedUrl->visits, $this->em); $io->text(sprintf('%s: %s', $longUrl, $resultMessage)); } } diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index a925c5d5..60209fa1 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -14,7 +14,7 @@ use function sprintf; final class ShortUrlImporting { - private function __construct(private ShortUrl $shortUrl, private bool $isNew) + private function __construct(private readonly ShortUrl $shortUrl, private readonly bool $isNew) { } @@ -29,7 +29,7 @@ final class ShortUrlImporting } /** - * @param iterable|ImportedShlinkVisit[] $visits + * @param iterable $visits */ public function importVisits(iterable $visits, EntityManagerInterface $em): string { @@ -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(Chronos::instance($importedVisit->date()))) { + if ($mostRecentImportedDate?->gte(Chronos::instance($importedVisit->date))) { continue; } diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php deleted file mode 100644 index 74b85388..00000000 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ /dev/null @@ -1,50 +0,0 @@ - $this->shortUrlTransformer->transform($visit->getShortUrl()), - 'visit' => $visit, - ])); - } - - public function newOrphanVisitUpdate(Visit $visit): Update - { - return new Update(self::NEW_ORPHAN_VISIT_TOPIC, json_encode([ - 'visit' => $this->orphanVisitTransformer->transform($visit), - ])); - } - - public function newShortUrlVisitUpdate(Visit $visit): Update - { - $shortUrl = $visit->getShortUrl(); - $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode()); - - return new Update($topic, json_encode([ - 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), - 'visit' => $visit, - ])); - } -} diff --git a/module/Core/src/Model/AbstractInfinitePaginableListParams.php b/module/Core/src/Model/AbstractInfinitePaginableListParams.php index ae107fdc..d4b2aaab 100644 --- a/module/Core/src/Model/AbstractInfinitePaginableListParams.php +++ b/module/Core/src/Model/AbstractInfinitePaginableListParams.php @@ -10,8 +10,8 @@ abstract class AbstractInfinitePaginableListParams { private const FIRST_PAGE = 1; - private int $page; - private int $itemsPerPage; + public readonly int $page; + public readonly int $itemsPerPage; protected function __construct(?int $page, ?int $itemsPerPage) { @@ -28,14 +28,4 @@ abstract class AbstractInfinitePaginableListParams { return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage; } - - public function getPage(): int - { - return $this->page; - } - - public function getItemsPerPage(): int - { - return $this->itemsPerPage; - } } diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index bd648227..5adbb161 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -8,7 +8,7 @@ final class Ordering { private const DEFAULT_DIR = 'ASC'; - private function __construct(private ?string $field, private string $dir) + private function __construct(public readonly ?string $field, public readonly string $direction) { } @@ -26,16 +26,6 @@ final class Ordering return self::fromTuple([null, null]); } - public function orderField(): ?string - { - return $this->field; - } - - public function orderDirection(): string - { - return $this->dir; - } - public function hasOrderField(): bool { return $this->field !== null; diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index d27d1fe6..2d39d657 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -12,8 +12,9 @@ use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use function array_key_exists; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; -use function Shlinkio\Shlink\Core\parseDateField; +use function Shlinkio\Shlink\Core\normalizeDate; +// TODO Rename to ShortUrlEdition final class ShortUrlEdit implements TitleResolutionModelInterface { private bool $longUrlPropWasProvided = false; @@ -69,8 +70,8 @@ final class ShortUrlEdit implements TitleResolutionModelInterface $this->forwardQueryPropWasProvided = array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data); $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false; $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS); diff --git a/module/Core/src/Model/ShortUrlIdentifier.php b/module/Core/src/Model/ShortUrlIdentifier.php index 815a5313..d2d6cbbc 100644 --- a/module/Core/src/Model/ShortUrlIdentifier.php +++ b/module/Core/src/Model/ShortUrlIdentifier.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputInterface; final class ShortUrlIdentifier { - public function __construct(private string $shortCode, private ?string $domain = null) + private function __construct(public readonly string $shortCode, public readonly ?string $domain = null) { } @@ -54,14 +54,4 @@ final class ShortUrlIdentifier { return new self($shortCode, $domain); } - - public function shortCode(): string - { - return $this->shortCode; - } - - public function domain(): ?string - { - return $this->domain; - } } diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index 86f2c9d1..e5b621c2 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -12,10 +12,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; -use function Shlinkio\Shlink\Core\parseDateField; +use function Shlinkio\Shlink\Core\normalizeDate; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; +// TODO Rename to ShortUrlCreation final class ShortUrlMeta implements TitleResolutionModelInterface { private string $longUrl; @@ -68,8 +69,8 @@ final class ShortUrlMeta implements TitleResolutionModelInterface } $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL); - $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); - $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); + $this->validSince = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)); + $this->validUntil = normalizeDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)); $this->customSlug = $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG); $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS); $this->findIfExists = $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS); diff --git a/module/Core/src/Model/ShortUrlsParams.php b/module/Core/src/Model/ShortUrlsParams.php index 9abfd10f..bd6dc556 100644 --- a/module/Core/src/Model/ShortUrlsParams.php +++ b/module/Core/src/Model/ShortUrlsParams.php @@ -6,24 +6,22 @@ namespace Shlinkio\Shlink\Core\Model; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter; use function Shlinkio\Shlink\Common\buildDateRange; -use function Shlinkio\Shlink\Core\parseDateField; +use function Shlinkio\Shlink\Core\normalizeDate; final class ShortUrlsParams { public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits']; public const DEFAULT_ITEMS_PER_PAGE = 10; - public const TAGS_MODE_ANY = 'any'; - public const TAGS_MODE_ALL = 'all'; private int $page; private int $itemsPerPage; private ?string $searchTerm; private array $tags; - /** @var self::TAGS_MODE_ANY|self::TAGS_MODE_ALL */ - private string $tagsMode = self::TAGS_MODE_ANY; + private TagsMode $tagsMode = TagsMode::ANY; private Ordering $orderBy; private ?DateRange $dateRange; @@ -61,14 +59,23 @@ final class ShortUrlsParams $this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM); $this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS); $this->dateRange = buildDateRange( - parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)), - parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)), + normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)), + normalizeDate($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)), ); $this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY)); $this->itemsPerPage = (int) ( $inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE ); - $this->tagsMode = $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE) ?? self::TAGS_MODE_ANY; + $this->tagsMode = $this->resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)); + } + + private function resolveTagsMode(?string $rawTagsMode): TagsMode + { + if ($rawTagsMode === null) { + return TagsMode::ANY; + } + + return TagsMode::tryFrom($rawTagsMode) ?? TagsMode::ANY; } public function page(): int @@ -101,10 +108,7 @@ final class ShortUrlsParams return $this->dateRange; } - /** - * @return self::TAGS_MODE_ANY|self::TAGS_MODE_ALL - */ - public function tagsMode(): string + public function tagsMode(): TagsMode { return $this->tagsMode; } diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index 9436e900..2207fad8 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -18,10 +18,10 @@ final class Visitor public const REMOTE_ADDRESS_MAX_LENGTH = 256; public const VISITED_URL_MAX_LENGTH = 2048; - private string $userAgent; - private string $referer; - private string $visitedUrl; - private ?string $remoteAddress; + public readonly string $userAgent; + public readonly string $referer; + public readonly string $visitedUrl; + public readonly ?string $remoteAddress; private bool $potentialBot; public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl) @@ -61,26 +61,6 @@ final class Visitor return new self('cf-facebook', '', null, ''); } - public function getUserAgent(): string - { - return $this->userAgent; - } - - public function getReferer(): string - { - return $this->referer; - } - - public function getRemoteAddress(): ?string - { - return $this->remoteAddress; - } - - public function getVisitedUrl(): string - { - return $this->visitedUrl; - } - public function isPotentialBot(): bool { return $this->potentialBot; diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index 718a4bc5..dfc4663d 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -10,16 +10,16 @@ use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; final class VisitsParams extends AbstractInfinitePaginableListParams { - private DateRange $dateRange; + public readonly DateRange $dateRange; public function __construct( ?DateRange $dateRange = null, ?int $page = null, ?int $itemsPerPage = null, - private bool $excludeBots = false, + public readonly bool $excludeBots = false, ) { parent::__construct($page, $itemsPerPage); - $this->dateRange = $dateRange ?? DateRange::emptyInstance(); + $this->dateRange = $dateRange ?? DateRange::allTime(); } public static function fromRawData(array $query): self @@ -31,14 +31,4 @@ final class VisitsParams extends AbstractInfinitePaginableListParams isset($query['excludeBots']), ); } - - public function getDateRange(): DateRange - { - return $this->dateRange; - } - - public function excludeBots(): bool - { - return $this->excludeBots; - } } diff --git a/module/Core/src/Options/RabbitMqOptions.php b/module/Core/src/Options/RabbitMqOptions.php new file mode 100644 index 00000000..388cd2ea --- /dev/null +++ b/module/Core/src/Options/RabbitMqOptions.php @@ -0,0 +1,40 @@ +enabled; + } + + protected function setEnabled(bool $enabled): self + { + $this->enabled = $enabled; + return $this; + } + + /** @deprecated */ + public function legacyVisitsPublishing(): bool + { + return $this->legacyVisitsPublishing; + } + + /** @deprecated */ + protected function setLegacyVisitsPublishing(bool $legacyVisitsPublishing): self + { + $this->legacyVisitsPublishing = $legacyVisitsPublishing; + return $this; + } +} diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 57f4bc37..38e185c2 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -6,12 +6,39 @@ namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; +use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; + class UrlShortenerOptions extends AbstractOptions { protected $__strictMode__ = false; // phpcs:ignore + private array $domain = []; + private int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH; private bool $autoResolveTitles = false; private bool $appendExtraPath = false; + private bool $multiSegmentSlugsEnabled = false; + + public function domain(): array + { + return $this->domain; + } + + protected function setDomain(array $domain): self + { + $this->domain = $domain; + return $this; + } + + public function defaultShortCodesLength(): int + { + return $this->defaultShortCodesLength; + } + + protected function setDefaultShortCodesLength(int $defaultShortCodesLength): self + { + $this->defaultShortCodesLength = $defaultShortCodesLength; + return $this; + } public function autoResolveTitles(): bool { @@ -32,4 +59,14 @@ class UrlShortenerOptions extends AbstractOptions { $this->appendExtraPath = $appendExtraPath; } + + public function multiSegmentSlugsEnabled(): bool + { + return $this->multiSegmentSlugsEnabled; + } + + protected function setMultiSegmentSlugsEnabled(bool $multiSegmentSlugsEnabled): void + { + $this->multiSegmentSlugsEnabled = $multiSegmentSlugsEnabled; + } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 9852c530..406d2b80 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -47,8 +47,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU private function processOrderByForList(QueryBuilder $qb, Ordering $orderBy): array { - $fieldName = $orderBy->orderField(); - $order = $orderBy->orderDirection(); + $fieldName = $orderBy->field; + $order = $orderBy->direction; if ($fieldName === 'visits') { // FIXME This query is inefficient. @@ -84,13 +84,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ->where('1=1'); $dateRange = $filtering->dateRange(); - if ($dateRange?->startDate() !== null) { + if ($dateRange?->startDate !== null) { $qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate')); - $qb->setParameter('startDate', $dateRange->startDate(), ChronosDateTimeType::CHRONOS_DATETIME); + $qb->setParameter('startDate', $dateRange->startDate, ChronosDateTimeType::CHRONOS_DATETIME); } - if ($dateRange?->endDate() !== null) { + if ($dateRange?->endDate !== null) { $qb->andWhere($qb->expr()->lte('s.dateCreated', ':endDate')); - $qb->setParameter('endDate', $dateRange->endDate(), ChronosDateTimeType::CHRONOS_DATETIME); + $qb->setParameter('endDate', $dateRange->endDate, ChronosDateTimeType::CHRONOS_DATETIME); } $searchTerm = $filtering->searchTerm(); @@ -102,22 +102,29 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $qb->leftJoin('s.tags', 't'); } - // Apply search conditions + // Apply general search conditions + $conditions = [ + $qb->expr()->like('s.longUrl', ':searchPattern'), + $qb->expr()->like('s.shortCode', ':searchPattern'), + $qb->expr()->like('s.title', ':searchPattern'), + $qb->expr()->like('d.authority', ':searchPattern'), + ]; + + // Apply tag conditions, only when not filtering by all provided tags + $tagsMode = $filtering->tagsMode() ?? TagsMode::ANY; + if (empty($tags) || $tagsMode === TagsMode::ANY) { + $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); + } + $qb->leftJoin('s.domain', 'd') - ->andWhere($qb->expr()->orX( - $qb->expr()->like('s.longUrl', ':searchPattern'), - $qb->expr()->like('s.shortCode', ':searchPattern'), - $qb->expr()->like('s.title', ':searchPattern'), - $qb->expr()->like('t.name', ':searchPattern'), - $qb->expr()->like('d.authority', ':searchPattern'), - )) + ->andWhere($qb->expr()->orX(...$conditions)) ->setParameter('searchPattern', '%' . $searchTerm . '%'); } // Filter by tags if provided if (! empty($tags)) { - $tagsMode = $filtering->tagsMode() ?? ShortUrlsParams::TAGS_MODE_ANY; - $tagsMode === ShortUrlsParams::TAGS_MODE_ANY + $tagsMode = $filtering->tagsMode() ?? TagsMode::ANY; + $tagsMode === TagsMode::ANY ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) : $this->joinAllTags($qb, $tags); } @@ -146,8 +153,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $query = $this->getEntityManager()->createQuery($dql); $query->setMaxResults(1) ->setParameters([ - 'shortCode' => $identifier->shortCode(), - 'domain' => $identifier->domain(), + 'shortCode' => $identifier->shortCode, + 'domain' => $identifier->domain, ]); // Since we ordered by domain, we will have first the URL matching provided domain, followed by the one @@ -198,10 +205,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $qb->from(ShortUrl::class, 's') ->where($qb->expr()->isNotNull('s.shortCode')) ->andWhere($qb->expr()->eq('s.shortCode', ':slug')) - ->setParameter('slug', $identifier->shortCode()) + ->setParameter('slug', $identifier->shortCode) ->setMaxResults(1); - $this->whereDomainIs($qb, $identifier->domain()); + $this->whereDomainIs($qb, $identifier->domain); $this->applySpecification($qb, $spec, 's'); @@ -277,12 +284,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU { $qb = $this->createQueryBuilder('s'); $qb->andWhere($qb->expr()->eq('s.importOriginalShortCode', ':shortCode')) - ->setParameter('shortCode', $url->shortCode()) + ->setParameter('shortCode', $url->shortCode) ->andWhere($qb->expr()->eq('s.importSource', ':importSource')) - ->setParameter('importSource', $url->source()) + ->setParameter('importSource', $url->source->value) ->setMaxResults(1); - $this->whereDomainIs($qb, $url->domain()); + $this->whereDomainIs($qb, $url->domain); return $qb->getQuery()->getOneOrNullResult(); } diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index aa24e0a1..2c4e8db6 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -41,8 +41,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { - $orderField = $filtering?->orderBy()?->orderField(); - $orderDir = $filtering?->orderBy()?->orderDirection(); + $orderField = $filtering?->orderBy?->field; + $orderDir = $filtering?->orderBy?->direction; $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField); $conn = $this->getEntityManager()->getConnection(); @@ -51,16 +51,16 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito if (! $orderMainQuery) { $subQb->orderBy('t.name', $orderDir ?? 'ASC') - ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset() ?? 0); + ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset ?? 0); } - $searchTerm = $filtering?->searchTerm(); + $searchTerm = $filtering?->searchTerm; if ($searchTerm !== null) { $subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); } - $apiKey = $filtering?->apiKey(); + $apiKey = $filtering?->apiKey; $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't'); // A native query builder needs to be used here, because DQL and ORM query builders do not support @@ -74,21 +74,20 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito 'COUNT(DISTINCT s.id) AS short_urls_count', 'COUNT(DISTINCT v.id) AS visits_count', ) - ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') + ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) ->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id')) ->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('s.id', 'v.short_url_id')) ->groupBy('t.id_0', 't.name_1'); // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates - $apiKey?->mapRoles(static fn (string $roleName, array $meta) => match ($roleName) { + $apiKey?->mapRoles(static fn (Role $role, array $meta) => match ($role) { Role::DOMAIN_SPECIFIC => $nativeQb->andWhere( $nativeQb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))), ), Role::AUTHORED_SHORT_URLS => $nativeQb->andWhere( $nativeQb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), ), - default => $nativeQb, }); if ($orderMainQuery) { @@ -97,8 +96,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count', $orderDir ?? 'ASC', ) - ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset() ?? 0); + ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset ?? 0); } // Add ordering by tag name, as a fallback in case of same amount, or as default ordering diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index b43d676d..f24153fc 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -86,7 +86,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function findVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsListFiltering $filtering): array { $qb = $this->createVisitsByShortCodeQueryBuilder($identifier, $filtering); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsCountFiltering $filtering): int @@ -103,7 +103,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); - $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey()?->spec())?->getId() ?? '-1'; + $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey?->spec())?->getId() ?? '-1'; // Parameters in this query need to be part of the query itself, as we need to use it as sub-query later // Since they are not provided by the caller, it's reasonably safe @@ -111,12 +111,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb->from(Visit::class, 'v') ->where($qb->expr()->eq('v.shortUrl', $shortUrlId)); - if ($filtering->excludeBots()) { + if ($filtering->excludeBots) { $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); } // Apply date range filtering - $this->applyDatesInline($qb, $filtering->dateRange()); + $this->applyDatesInline($qb, $filtering->dateRange); return $qb; } @@ -124,7 +124,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array { $qb = $this->createVisitsByTagQueryBuilder($tag, $filtering); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int @@ -144,12 +144,53 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ->join('s.tags', 't') ->where($qb->expr()->eq('t.name', $this->getEntityManager()->getConnection()->quote($tag))); - if ($filtering->excludeBots()) { + if ($filtering->excludeBots) { $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); } - $this->applyDatesInline($qb, $filtering->dateRange()); - $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v'); + $this->applyDatesInline($qb, $filtering->dateRange); + $this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v'); + + return $qb; + } + + /** + * @return Visit[] + */ + public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array + { + $qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); + } + + public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int + { + $qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering); + $qb->select('COUNT(v.id)'); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + private function createVisitsByDomainQueryBuilder(string $domain, VisitsCountFiltering $filtering): QueryBuilder + { + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later. + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb->from(Visit::class, 'v') + ->join('v.shortUrl', 's'); + + if ($domain === 'DEFAULT') { + $qb->where($qb->expr()->isNull('s.domain')); + } else { + $qb->join('s.domain', 'd') + ->where($qb->expr()->eq('d.authority', $this->getEntityManager()->getConnection()->quote($domain))); + } + + if ($filtering->excludeBots) { + $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); + } + + $this->applyDatesInline($qb, $filtering->dateRange); + $this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v'); return $qb; } @@ -158,7 +199,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo { $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNull('v.shortUrl')); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countOrphanVisits(VisitsCountFiltering $filtering): int @@ -174,9 +215,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNotNull('v.shortUrl')); - $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec()); + $this->applySpecification($qb, $filtering->apiKey?->inlinedSpec()); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countNonOrphanVisits(VisitsCountFiltering $filtering): int @@ -191,11 +232,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Visit::class, 'v'); - if ($filtering->excludeBots()) { + if ($filtering->excludeBots) { $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); } - $this->applyDatesInline($qb, $filtering->dateRange()); + $this->applyDatesInline($qb, $filtering->dateRange); return $qb; } @@ -204,11 +245,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo { $conn = $this->getEntityManager()->getConnection(); - if ($dateRange?->startDate() !== null) { - $qb->andWhere($qb->expr()->gte('v.date', $conn->quote($dateRange->startDate()->toDateTimeString()))); + if ($dateRange?->startDate !== null) { + $qb->andWhere($qb->expr()->gte('v.date', $conn->quote($dateRange->startDate->toDateTimeString()))); } - if ($dateRange?->endDate() !== null) { - $qb->andWhere($qb->expr()->lte('v.date', $conn->quote($dateRange->endDate()->toDateTimeString()))); + if ($dateRange?->endDate !== null) { + $qb->andWhere($qb->expr()->lte('v.date', $conn->quote($dateRange->endDate->toDateTimeString()))); } } @@ -231,6 +272,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder(); $nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*') ->from('visits', 'v') + // @phpstan-ignore-next-line ->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id')) ->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id')) ->orderBy('v.id', 'DESC'); diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 3d480c01..837dea1b 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -45,6 +45,13 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int; + /** + * @return Visit[] + */ + public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array; + + public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int; + /** * @return Visit[] */ diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index ec79858f..21afb6b0 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM\EntityManagerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -17,10 +19,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; class UrlShortener implements UrlShortenerInterface { public function __construct( - private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private EntityManagerInterface $em, - private ShortUrlRelationResolverInterface $relationResolver, - private ShortCodeUniquenessHelperInterface $shortCodeHelper, + private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private readonly EntityManagerInterface $em, + private readonly ShortUrlRelationResolverInterface $relationResolver, + private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, + private readonly EventDispatcherInterface $eventDispatcher, ) { } @@ -39,7 +42,8 @@ class UrlShortener implements UrlShortenerInterface /** @var ShortUrlMeta $meta */ $meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta); - return $this->em->wrapInTransaction(function () use ($meta) { + /** @var ShortUrl $newShortUrl */ + $newShortUrl = $this->em->wrapInTransaction(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); $this->verifyShortCodeUniqueness($meta, $shortUrl); @@ -47,6 +51,10 @@ class UrlShortener implements UrlShortenerInterface return $shortUrl; }); + + $this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId())); + + return $newShortUrl; } private function findExistingShortUrlIfExists(ShortUrlMeta $meta): ?ShortUrl diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 9d92067c..bb350aa2 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -18,19 +18,21 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -use function array_pad; +use function array_slice; +use function count; use function explode; +use function implode; use function sprintf; use function trim; class ExtraPathRedirectMiddleware implements MiddlewareInterface { public function __construct( - private ShortUrlResolverInterface $resolver, - private RequestTrackerInterface $requestTracker, - private ShortUrlRedirectionBuilderInterface $redirectionBuilder, - private RedirectResponseHelperInterface $redirectResponseHelper, - private UrlShortenerOptions $urlShortenerOptions, + private readonly ShortUrlResolverInterface $resolver, + private readonly RequestTrackerInterface $requestTracker, + private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder, + private readonly RedirectResponseHelperInterface $redirectResponseHelper, + private readonly UrlShortenerOptions $urlShortenerOptions, ) { } @@ -38,15 +40,36 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface { /** @var NotFoundType|null $notFoundType */ $notFoundType = $request->getAttribute(NotFoundType::class); - - // We'll apply this logic only if actively opted in and current URL is potentially /{shortCode}/[...] - if (! $notFoundType?->isRegularNotFound() || ! $this->urlShortenerOptions->appendExtraPath()) { + if (! $this->shouldApplyLogic($notFoundType)) { return $handler->handle($request); } + return $this->tryToResolveRedirect($request, $handler); + } + + private function shouldApplyLogic(?NotFoundType $notFoundType): bool + { + if ($notFoundType === null || ! $this->urlShortenerOptions->appendExtraPath()) { + return false; + } + + return ( + // If multi-segment slugs are enabled, the appropriate not-found type is "invalid_short_url" + $this->urlShortenerOptions->multiSegmentSlugsEnabled() && $notFoundType->isInvalidShortUrl() + ) || ( + // If multi-segment slugs are disabled, the appropriate not-found type is "regular_404" + ! $this->urlShortenerOptions->multiSegmentSlugsEnabled() && $notFoundType->isRegularNotFound() + ); + } + + private function tryToResolveRedirect( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + int $shortCodeSegments = 1, + ): ResponseInterface { $uri = $request->getUri(); $query = $request->getQueryParams(); - [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri); + [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri, $shortCodeSegments); $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority()); try { @@ -56,18 +79,23 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { - return $handler->handle($request); + if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled()) { + return $handler->handle($request); + } + + return $this->tryToResolveRedirect($request, $handler, $shortCodeSegments + 1); } } /** * @return array{0: string, 1: string|null} */ - private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri): array + private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri, int $shortCodeSegments): array { - $pathParts = explode('/', trim($uri->getPath(), '/'), 2); - [$potentialShortCode, $extraPath] = array_pad($pathParts, 2, null); + $parts = explode('/', trim($uri->getPath(), '/')); + $shortCode = array_slice($parts, 0, $shortCodeSegments); + $extraPath = array_slice($parts, $shortCodeSegments); - return [$potentialShortCode, $extraPath === null ? null : sprintf('/%s', $extraPath)]; + return [implode('/', $shortCode), count($extraPath) > 0 ? sprintf('/%s', implode('/', $extraPath)) : null]; } } diff --git a/module/Core/src/ShortUrl/Model/TagsMode.php b/module/Core/src/ShortUrl/Model/TagsMode.php new file mode 100644 index 00000000..593d6d83 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/TagsMode.php @@ -0,0 +1,11 @@ +tags; } - public function tagsMode(): ?string + public function tagsMode(): ?TagsMode { return $this->tagsMode; } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index 089915e3..04645126 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Persistence; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlsListFiltering extends ShortUrlsCountFiltering @@ -17,7 +18,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering private Ordering $orderBy, ?string $searchTerm = null, array $tags = [], - ?string $tagsMode = null, + ?TagsMode $tagsMode = null, ?DateRange $dateRange = null, ?ApiKey $apiKey = null, ) { diff --git a/module/Core/src/Spec/InDateRange.php b/module/Core/src/Spec/InDateRange.php index 05cb6f0a..994e6d63 100644 --- a/module/Core/src/Spec/InDateRange.php +++ b/module/Core/src/Spec/InDateRange.php @@ -20,12 +20,12 @@ class InDateRange extends BaseSpecification { $criteria = []; - if ($this->dateRange?->startDate() !== null) { - $criteria[] = Spec::gte($this->field, $this->dateRange->startDate()->toDateTimeString()); + if ($this->dateRange?->startDate !== null) { + $criteria[] = Spec::gte($this->field, $this->dateRange->startDate->toDateTimeString()); } - if ($this->dateRange?->endDate() !== null) { - $criteria[] = Spec::lte($this->field, $this->dateRange->endDate()->toDateTimeString()); + if ($this->dateRange?->endDate !== null) { + $criteria[] = Spec::lte($this->field, $this->dateRange->endDate->toDateTimeString()); } return Spec::andX(...$criteria); diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 6e917399..8a4f196b 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -8,23 +8,11 @@ use JsonSerializable; final class TagInfo implements JsonSerializable { - public function __construct(private string $tag, private int $shortUrlsCount, private int $visitsCount) - { - } - - public function tag(): string - { - return $this->tag; - } - - public function shortUrlsCount(): int - { - return $this->shortUrlsCount; - } - - public function visitsCount(): int - { - return $this->visitsCount; + public function __construct( + public readonly string $tag, + public readonly int $shortUrlsCount, + public readonly int $visitsCount, + ) { } public function jsonSerialize(): array diff --git a/module/Core/src/Tag/Model/TagRenaming.php b/module/Core/src/Tag/Model/TagRenaming.php index 3bdae21c..9c523b8b 100644 --- a/module/Core/src/Tag/Model/TagRenaming.php +++ b/module/Core/src/Tag/Model/TagRenaming.php @@ -10,7 +10,7 @@ use function sprintf; final class TagRenaming { - private function __construct(private string $oldName, private string $newName) + private function __construct(public readonly string $oldName, public readonly string $newName) { } @@ -31,16 +31,6 @@ final class TagRenaming return self::fromNames($payload['oldName'], $payload['newName']); } - public function oldName(): string - { - return $this->oldName; - } - - public function newName(): string - { - return $this->newName; - } - public function nameChanged(): bool { return $this->oldName !== $this->newName; diff --git a/module/Core/src/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php index 8f078788..236dde4a 100644 --- a/module/Core/src/Tag/Model/TagsListFiltering.php +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -10,41 +10,16 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class TagsListFiltering { public function __construct( - private ?int $limit = null, - private ?int $offset = null, - private ?string $searchTerm = null, - private ?Ordering $orderBy = null, - private ?ApiKey $apiKey = null, + public readonly ?int $limit = null, + public readonly ?int $offset = null, + public readonly ?string $searchTerm = null, + public readonly ?Ordering $orderBy = null, + public readonly ?ApiKey $apiKey = null, ) { } public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ?ApiKey $apiKey): self { - return new self($limit, $offset, $params->searchTerm(), $params->orderBy(), $apiKey); - } - - public function limit(): ?int - { - return $this->limit; - } - - public function offset(): ?int - { - return $this->offset; - } - - public function searchTerm(): ?string - { - return $this->searchTerm; - } - - public function orderBy(): ?Ordering - { - return $this->orderBy; - } - - public function apiKey(): ?ApiKey - { - return $this->apiKey; + return new self($limit, $offset, $params->searchTerm, $params->orderBy, $apiKey); } } diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index 3f40debe..633fd5f2 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -12,9 +12,9 @@ use function Shlinkio\Shlink\Common\parseOrderBy; final class TagsParams extends AbstractInfinitePaginableListParams { private function __construct( - private ?string $searchTerm, - private Ordering $orderBy, - private bool $withStats, + public readonly ?string $searchTerm, + public readonly Ordering $orderBy, + public readonly bool $withStats, ?int $page, ?int $itemsPerPage, ) { @@ -31,19 +31,4 @@ final class TagsParams extends AbstractInfinitePaginableListParams isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, ); } - - public function searchTerm(): ?string - { - return $this->searchTerm; - } - - public function orderBy(): Ordering - { - return $this->orderBy; - } - - public function withStats(): bool - { - return $this->withStats; - } } diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php index ba6bc78d..ee0086cd 100644 --- a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -30,7 +30,7 @@ abstract class AbstractTagsPaginatorAdapter implements AdapterInterface new WithApiKeySpecsEnsuringJoin($this->apiKey), ]; - $searchTerm = $this->params->searchTerm(); + $searchTerm = $this->params->searchTerm; if ($searchTerm !== null) { $conditions[] = Spec::like('name', $searchTerm); } diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php index d6bc0b7b..7d54940e 100644 --- a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php @@ -15,13 +15,13 @@ class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter new WithApiKeySpecsEnsuringJoin($this->apiKey), Spec::orderBy( 'name', // Ordering by other fields makes no sense here - $this->params->orderBy()->orderDirection(), + $this->params->orderBy->direction, ), Spec::limit($length), Spec::offset($offset), ]; - $searchTerm = $this->params->searchTerm(); + $searchTerm = $this->params->searchTerm; if ($searchTerm !== null) { $conditions[] = Spec::like('name', $searchTerm); } diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 40eb413f..b8d7f710 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -49,8 +49,8 @@ class TagService implements TagServiceInterface private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator { return (new Paginator($adapter)) - ->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); + ->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); } /** @@ -83,17 +83,17 @@ class TagService implements TagServiceInterface $repo = $this->em->getRepository(Tag::class); /** @var Tag|null $tag */ - $tag = $repo->findOneBy(['name' => $renaming->oldName()]); + $tag = $repo->findOneBy(['name' => $renaming->oldName]); if ($tag === null) { - throw TagNotFoundException::fromTag($renaming->oldName()); + throw TagNotFoundException::fromTag($renaming->oldName); } - $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName()]) > 0; + $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName]) > 0; if ($newNameExists) { throw TagConflictException::forExistingTag($renaming); } - $tag->rename($renaming->newName()); + $tag->rename($renaming->newName); $this->em->flush(); return $tag; diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php index da061c0d..2e2965b1 100644 --- a/module/Core/src/Util/UrlValidator.php +++ b/module/Core/src/Util/UrlValidator.php @@ -11,8 +11,13 @@ use GuzzleHttp\RequestOptions; use Psr\Http\Message\ResponseInterface; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use Throwable; +use function html_entity_decode; use function preg_match; +use function str_contains; +use function str_starts_with; +use function strtolower; use function trim; use const Shlinkio\Shlink\TITLE_TAG_VALUE; @@ -36,7 +41,7 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface return; } - $this->validateUrlAndGetResponse($url, true); + $this->validateUrlAndGetResponse($url); } public function validateUrlWithTitle(string $url, bool $doValidate): ?string @@ -45,31 +50,61 @@ class UrlValidator implements UrlValidatorInterface, RequestMethodInterface return null; } - $response = $this->validateUrlAndGetResponse($url, $doValidate); - if ($response === null || ! $this->options->autoResolveTitles()) { + if (! $this->options->autoResolveTitles()) { + $this->validateUrlAndGetResponse($url, self::METHOD_HEAD); return null; } - $body = $response->getBody()->__toString(); - preg_match(TITLE_TAG_VALUE, $body, $matches); - return isset($matches[1]) ? trim($matches[1]) : null; + $response = $doValidate ? $this->validateUrlAndGetResponse($url) : $this->getResponse($url); + if ($response === null) { + return null; + } + + $contentType = strtolower($response->getHeaderLine('Content-Type')); + if (! str_starts_with($contentType, 'text/html')) { + return null; + } + + $collectedBody = ''; + $body = $response->getBody(); + // With streaming enabled, we can walk the body until the tag is found, and then stop + while (! str_contains($collectedBody, '') && ! $body->eof()) { + $collectedBody .= $body->read(1024); + } + preg_match(TITLE_TAG_VALUE, $collectedBody, $matches); + return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null; } - private function validateUrlAndGetResponse(string $url, bool $throwOnError): ?ResponseInterface + /** + * @param self::METHOD_GET|self::METHOD_HEAD $method + * @throws InvalidUrlException + */ + private function validateUrlAndGetResponse(string $url, string $method = self::METHOD_GET): ResponseInterface { try { - return $this->httpClient->request(self::METHOD_GET, $url, [ + return $this->httpClient->request($method, $url, [ RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], RequestOptions::IDN_CONVERSION => true, // Making the request with a browser's user agent makes the validation closer to a real user RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT], + RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed ]); } catch (GuzzleException $e) { - if ($throwOnError) { - throw InvalidUrlException::fromUrl($url, $e); - } + throw InvalidUrlException::fromUrl($url, $e); + } + } + private function getResponse(string $url): ?ResponseInterface + { + try { + return $this->validateUrlAndGetResponse($url); + } catch (Throwable) { return null; } } + + private function normalizeTitle(string $title): string + { + return html_entity_decode(trim($title)); + } } diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index 6cd578fb..283d9a94 100644 --- a/module/Core/src/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -10,11 +10,13 @@ use Laminas\InputFilter\Input; use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function is_string; use function str_replace; use function substr; +use function trim; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; @@ -39,7 +41,7 @@ class ShortUrlInputFilter extends InputFilter private function __construct(array $data, bool $requireLongUrl) { - $this->initialize($requireLongUrl); + $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); $this->setData($data); } @@ -53,7 +55,7 @@ class ShortUrlInputFilter extends InputFilter return new self($data, false); } - private function initialize(bool $requireLongUrl): void + private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void { $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ @@ -76,9 +78,10 @@ class ShortUrlInputFilter extends InputFilter // FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's // empty, is by using the deprecated setContinueIfEmpty $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); - $customSlug->getFilterChain()->attach(new Filter\Callback( - static fn (mixed $value) => is_string($value) ? str_replace([' ', '/'], '-', $value) : $value, - )); + $customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { + true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v, + false => static fn (mixed $v) => is_string($v) ? str_replace([' ', '/'], '-', $v) : $v, + })); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::STRING, Validator\NotEmpty::SPACE, diff --git a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php index 6c0443aa..50953310 100644 --- a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php @@ -9,6 +9,7 @@ use Laminas\Validator\InArray; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; class ShortUrlsParamsInputFilter extends InputFilter { @@ -43,7 +44,7 @@ class ShortUrlsParamsInputFilter extends InputFilter $tagsMode = $this->createInput(self::TAGS_MODE, false); $tagsMode->getValidatorChain()->attach(new InArray([ - 'haystack' => [ShortUrlsParams::TAGS_MODE_ALL, ShortUrlsParams::TAGS_MODE_ANY], + 'haystack' => [TagsMode::ALL->value, TagsMode::ANY->value], 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); diff --git a/module/Core/src/Visit/Model/UnknownVisitLocation.php b/module/Core/src/Visit/Model/UnknownVisitLocation.php deleted file mode 100644 index b8926bd5..00000000 --- a/module/Core/src/Visit/Model/UnknownVisitLocation.php +++ /dev/null @@ -1,41 +0,0 @@ - 'Unknown', - 'countryName' => 'Unknown', - 'regionName' => 'Unknown', - 'cityName' => 'Unknown', - 'latitude' => 0.0, - 'longitude' => 0.0, - 'timezone' => 'Unknown', - ]; - } -} diff --git a/module/Core/src/Visit/Model/VisitLocationInterface.php b/module/Core/src/Visit/Model/VisitLocationInterface.php deleted file mode 100644 index 9a296a28..00000000 --- a/module/Core/src/Visit/Model/VisitLocationInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -visitRepository->countVisitsByDomain( + $this->domain, + new VisitsCountFiltering( + $this->params->dateRange, + $this->params->excludeBots, + $this->apiKey, + ), + ); + } + + public function getSlice(int $offset, int $length): iterable + { + return $this->visitRepository->findVisitsByDomain( + $this->domain, + new VisitsListFiltering( + $this->params->dateRange, + $this->params->excludeBots, + $this->apiKey, + $length, + $offset, + ), + ); + } +} diff --git a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php index ba5b6663..5f06ea09 100644 --- a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php @@ -23,8 +23,8 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda protected function doCount(): int { return $this->repo->countNonOrphanVisits(new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, )); } @@ -32,8 +32,8 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda public function getSlice(int $offset, int $length): iterable { return $this->repo->findNonOrphanVisits(new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $length, $offset, diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 8a47c9d7..f18dbb05 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -19,16 +19,16 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte protected function doCount(): int { return $this->repo->countOrphanVisits(new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, )); } public function getSlice(int $offset, int $length): iterable { return $this->repo->findOrphanVisits(new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, null, $length, $offset, diff --git a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 2e47fbf8..5169c327 100644 --- a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -27,8 +27,8 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap return $this->visitRepository->findVisitsByShortCode( $this->identifier, new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $length, $offset, @@ -41,8 +41,8 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap return $this->visitRepository->countVisitsByShortCode( $this->identifier, new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, ), ); diff --git a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php index 162b6cba..aed79d02 100644 --- a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -26,8 +26,8 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter return $this->visitRepository->findVisitsByTag( $this->tag, new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $length, $offset, @@ -40,8 +40,8 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter return $this->visitRepository->countVisitsByTag( $this->tag, new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, ), ); diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index 140ec9b9..f839a945 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsCountFiltering { public function __construct( - private ?DateRange $dateRange = null, - private bool $excludeBots = false, - private ?ApiKey $apiKey = null, + public readonly ?DateRange $dateRange = null, + public readonly bool $excludeBots = false, + public readonly ?ApiKey $apiKey = null, ) { } @@ -20,19 +20,4 @@ class VisitsCountFiltering { return new self(null, false, $apiKey); } - - public function dateRange(): ?DateRange - { - return $this->dateRange; - } - - public function excludeBots(): bool - { - return $this->excludeBots; - } - - public function apiKey(): ?ApiKey - { - return $this->apiKey; - } } diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php index b17964a6..747a3ce0 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -13,19 +13,9 @@ final class VisitsListFiltering extends VisitsCountFiltering ?DateRange $dateRange = null, bool $excludeBots = false, ?ApiKey $apiKey = null, - private ?int $limit = null, - private ?int $offset = null, + public readonly ?int $limit = null, + public readonly ?int $offset = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey); } - - public function limit(): ?int - { - return $this->limit; - } - - public function offset(): ?int - { - return $this->offset; - } } diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index dc45e12f..1887dbfd 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -24,8 +24,10 @@ use function str_contains; class RequestTracker implements RequestTrackerInterface, RequestMethodInterface { - public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions) - { + public function __construct( + private readonly VisitsTrackerInterface $visitsTracker, + private readonly TrackingOptions $trackingOptions, + ) { } public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void @@ -45,10 +47,11 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface $notFoundType = $request->getAttribute(NotFoundType::class); $visitor = Visitor::fromRequest($request); - match (true) { // @phpstan-ignore-line + match (true) { $notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor), $notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor), $notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor), + default => null, }; } diff --git a/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php index 52be52a8..a7b2a1d6 100644 --- a/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php @@ -22,14 +22,14 @@ class CountOfNonOrphanVisits extends BaseSpecification { $conditions = [ Spec::isNotNull('shortUrl'), - new InDateRange($this->filtering->dateRange()), + new InDateRange($this->filtering->dateRange), ]; - if ($this->filtering->excludeBots()) { + if ($this->filtering->excludeBots) { $conditions[] = Spec::eq('potentialBot', false); } - $apiKey = $this->filtering->apiKey(); + $apiKey = $this->filtering->apiKey; if ($apiKey !== null) { $conditions[] = new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl'); } diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php index d8e6b2d2..106350c6 100644 --- a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -21,10 +21,10 @@ class CountOfOrphanVisits extends BaseSpecification { $conditions = [ Spec::isNull('shortUrl'), - new InDateRange($this->filtering->dateRange()), + new InDateRange($this->filtering->dateRange), ]; - if ($this->filtering->excludeBots()) { + if ($this->filtering->excludeBots) { $conditions[] = Spec::eq('potentialBot', false); } diff --git a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php index 9f4842f5..0da5f4ba 100644 --- a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php +++ b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php @@ -11,13 +11,12 @@ class OrphanVisitDataTransformer implements DataTransformerInterface { /** * @param Visit $visit - * @return array */ public function transform($visit): array // phpcs:ignore { $serializedVisit = $visit->jsonSerialize(); $serializedVisit['visitedUrl'] = $visit->visitedUrl(); - $serializedVisit['type'] = $visit->type(); + $serializedVisit['type'] = $visit->type()->value; return $serializedVisit; } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 914a9c5b..4f19103f 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -7,9 +7,12 @@ namespace Shlinkio\Shlink\Core\Visit; use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Adapter\AdapterInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -19,6 +22,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; @@ -85,6 +89,24 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params); } + /** + * @return Visit[]|Paginator + * @throws DomainNotFoundException + */ + public function visitsForDomain(string $domain, VisitsParams $params, ?ApiKey $apiKey = null): Paginator + { + /** @var DomainRepository $domainRepo */ + $domainRepo = $this->em->getRepository(Domain::class); + if ($domain !== 'DEFAULT' && ! $domainRepo->domainExists($domain, $apiKey)) { + throw DomainNotFoundException::fromAuthority($domain); + } + + /** @var VisitRepositoryInterface $repo */ + $repo = $this->em->getRepository(Visit::class); + + return $this->createPaginator(new DomainVisitsPaginatorAdapter($repo, $domain, $params, $apiKey), $params); + } + /** * @return Visit[]|Paginator */ @@ -107,8 +129,8 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator { $paginator = new Paginator($adapter); - $paginator->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); + $paginator->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); return $paginator; } diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 3616b531..b32fc99d 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -33,6 +34,12 @@ interface VisitsStatsHelperInterface */ public function visitsForTag(string $tag, VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + /** + * @return Visit[]|Paginator + * @throws DomainNotFoundException + */ + public function visitsForDomain(string $domain, 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 f4e5bf92..3aef46df 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -72,6 +72,6 @@ class VisitsTracker implements VisitsTrackerInterface $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); } } diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 3f69e7d9..d1b3bbeb 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -55,6 +55,10 @@ class DomainRepositoryTest extends DatabaseTestCase self::assertEquals($detachedWithRedirects, $this->repo->findOneByAuthority('detached-with-redirects.com')); self::assertNull($this->repo->findOneByAuthority('does-not-exist.com')); self::assertEquals($detachedDomain, $this->repo->findOneByAuthority('detached.com')); + self::assertTrue($this->repo->domainExists('bar.com')); + self::assertTrue($this->repo->domainExists('detached-with-redirects.com')); + self::assertFalse($this->repo->domainExists('does-not-exist.com')); + self::assertTrue($this->repo->domainExists('detached.com')); } /** @test */ @@ -115,6 +119,12 @@ class DomainRepositoryTest extends DatabaseTestCase $this->repo->findOneByAuthority('detached-with-redirects.com', $detachedWithRedirectsApiKey), ); self::assertNull($this->repo->findOneByAuthority('foo.com', $detachedWithRedirectsApiKey)); + + self::assertTrue($this->repo->domainExists('foo.com', $authorApiKey)); + self::assertFalse($this->repo->domainExists('bar.com', $authorApiKey)); + self::assertTrue($this->repo->domainExists('bar.com', $barDomainApiKey)); + self::assertTrue($this->repo->domainExists('detached-with-redirects.com', $detachedWithRedirectsApiKey)); + self::assertFalse($this->repo->domainExists('foo.com', $detachedWithRedirectsApiKey)); } private function createShortUrl(Domain $domain, ?ApiKey $apiKey = null): ShortUrl diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 4ad89629..b538f422 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -14,13 +14,14 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use Shlinkio\Shlink\Importer\Sources\ImportSource; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -128,6 +129,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar']))); self::assertSame($foo, $result[0]); + // Assert searched text also applies to tags + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'bar')); + self::assertCount(2, $result); + self::assertEquals(2, $this->repo->countList(new ShortUrlsCountFiltering('bar'))); + self::assertContains($foo, $result); + $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance())); self::assertCount(3, $result); @@ -146,23 +153,23 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame($bar, $result[0]); $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withEndDate( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::until( Chronos::now()->subDays(2), )), ); self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::withEndDate( + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::until( Chronos::now()->subDays(2), )))); self::assertSame($foo2, $result[0]); self::assertCount(2, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withStartDate( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::since( Chronos::now()->subDays(2), )), )); self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, [], null, DateRange::withStartDate(Chronos::now()->subDays(2))), + new ShortUrlsCountFiltering(null, [], null, DateRange::since(Chronos::now()->subDays(2))), )); } @@ -227,7 +234,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar'], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY, ))); self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( null, @@ -235,15 +242,11 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar'], - ShortUrlsParams::TAGS_MODE_ALL, + TagsMode::ALL, ))); self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar']))); - self::assertEquals(5, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY), - )); - self::assertEquals(1, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL), - )); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ANY))); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); self::assertCount(4, $this->repo->findList( new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']), @@ -254,7 +257,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY, ))); self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( null, @@ -262,14 +265,14 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ALL, + TagsMode::ALL, ))); self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz']))); self::assertEquals(4, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY), + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ANY), )); self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL), + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ALL), )); self::assertCount(5, $this->repo->findList( @@ -281,7 +284,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY, ))); self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( null, @@ -289,14 +292,14 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ALL, + TagsMode::ALL, ))); self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz']))); self::assertEquals(5, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY), + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ANY), )); self::assertEquals(0, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL), + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ALL), )); } @@ -599,7 +602,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase public function importedShortUrlsAreFoundWhenExpected(): void { $buildImported = static fn (string $shortCode, ?String $domain = null) => - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), $domain, $shortCode, null); + new ImportedShlinkUrl(ImportSource::BITLY, 'foo', [], Chronos::now(), $domain, $shortCode, null); $shortUrlWithoutDomain = ShortUrl::fromImport($buildImported('my-cool-slug'), true); $this->getEntityManager()->persist($shortUrlWithoutDomain); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index fe544376..87cd7280 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -64,7 +64,7 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist(new Tag($name)); } - $apiKey = $filtering?->apiKey(); + $apiKey = $filtering?->apiKey; if ($apiKey !== null) { $this->getEntityManager()->persist($apiKey); } @@ -101,9 +101,9 @@ class TagRepositoryTest extends DatabaseTestCase self::assertCount(count($expectedList), $result); foreach ($expectedList as $index => [$tag, $shortUrlsCount, $visitsCount]) { - self::assertEquals($shortUrlsCount, $result[$index]->shortUrlsCount()); - self::assertEquals($visitsCount, $result[$index]->visitsCount()); - self::assertEquals($tag, $result[$index]->tag()); + self::assertEquals($shortUrlsCount, $result[$index]->shortUrlsCount); + self::assertEquals($visitsCount, $result[$index]->visitsCount); + self::assertEquals($tag, $result[$index]->tag); } } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index c23bd8aa..041f5e4c 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -52,7 +52,7 @@ class VisitRepositoryTest extends DatabaseTestCase { $shortUrl = ShortUrl::createEmpty(); $this->getEntityManager()->persist($shortUrl); - $countIterable = function (iterable $results): int { + $countIterable = static function (iterable $results): int { $resultsCount = 0; foreach ($results as $value) { $resultsCount++; @@ -114,16 +114,16 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(2, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ), )); self::assertCount(4, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - new VisitsListFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsListFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); self::assertCount(1, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), - new VisitsListFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsListFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); self::assertCount(3, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), @@ -163,16 +163,16 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(2, $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsCountFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ), )); self::assertEquals(4, $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsCountFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); self::assertEquals(1, $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), - new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsCountFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); } @@ -227,10 +227,10 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(18, $this->repo->findVisitsByTag($foo, new VisitsListFiltering())); self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(null, true))); self::assertCount(6, $this->repo->findVisitsByTag($foo, new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -249,10 +249,58 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(12, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering())); self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(null, true))); self::assertEquals(4, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), + ))); + } + + /** @test */ + public function findVisitsByDomainReturnsProperData(): void + { + $this->createShortUrlsAndVisits('doma.in'); + $this->getEntityManager()->flush(); + + self::assertCount(0, $this->repo->findVisitsByDomain('invalid', new VisitsListFiltering())); + self::assertCount(6, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertCount(3, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering())); + self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); + self::assertCount(2, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::since(Chronos::parse('2016-01-03')), + ))); + self::assertCount(2, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertCount(4, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::since(Chronos::parse('2016-01-03')), + ))); + } + + /** @test */ + public function countVisitsByDomainReturnsProperData(): void + { + $this->createShortUrlsAndVisits('doma.in'); + $this->getEntityManager()->flush(); + + self::assertEquals(0, $this->repo->countVisitsByDomain('invalid', new VisitsListFiltering())); + self::assertEquals(6, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering())); + self::assertEquals(3, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering())); + self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); + self::assertEquals(2, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( + DateRange::since(Chronos::parse('2016-01-03')), + ))); + self::assertEquals(2, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + ))); + self::assertEquals(4, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( + DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -301,13 +349,13 @@ 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(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate( + self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-05')->startOfDay(), )))); - self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate( + self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-03')->startOfDay(), ), false, $apiKey1))); - self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate( + self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-07')->startOfDay(), ), false, $apiKey2))); self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(null, true, $apiKey2))); @@ -347,20 +395,20 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(5, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 5))); self::assertCount(10, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 15, 8))); self::assertCount(9, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::withStartDate(Chronos::parse('2020-01-04')), + DateRange::since(Chronos::parse('2020-01-04')), false, null, 15, ))); self::assertCount(2, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), false, null, 6, 4, ))); self::assertCount(3, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::withEndDate(Chronos::parse('2020-01-01')), + DateRange::until(Chronos::parse('2020-01-01')), ))); } @@ -389,15 +437,15 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering())); - self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering(DateRange::emptyInstance()))); + self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering(DateRange::allTime()))); self::assertEquals(9, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2020-01-04'))), + new VisitsCountFiltering(DateRange::since(Chronos::parse('2020-01-04'))), )); self::assertEquals(6, $this->repo->countOrphanVisits(new VisitsCountFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), ))); self::assertEquals(3, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::withEndDate(Chronos::parse('2020-01-01'))), + new VisitsCountFiltering(DateRange::until(Chronos::parse('2020-01-01'))), )); } @@ -419,22 +467,22 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering())); - self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::emptyInstance()))); - self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartDate( + self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::allTime()))); + self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::since( Chronos::parse('2016-01-05')->endOfDay(), )))); - self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withEndDate( + self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::until( Chronos::parse('2016-01-04')->endOfDay(), )))); - self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate( + self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between( Chronos::parse('2016-01-03')->startOfDay(), Chronos::parse('2016-01-04')->endOfDay(), )))); - self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate( + self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between( Chronos::parse('2016-01-03')->startOfDay(), Chronos::parse('2016-01-08')->endOfDay(), )))); - self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate( + self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between( Chronos::parse('2016-01-03')->startOfDay(), Chronos::parse('2016-01-08')->endOfDay(), ), false, null, 10, 10))); diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index 3eb1ad79..fdd291a5 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -37,9 +37,10 @@ class PixelActionTest extends TestCase public function imageIsReturned(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn( - ShortUrl::withLongUrl('http://domain.com/foo/bar'), - )->shouldBeCalledOnce(); + $this->urlResolver->resolveEnabledShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), + )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')) + ->shouldBeCalledOnce(); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldBeCalledOnce(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 419febec..fb9e4e6a 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -59,7 +59,7 @@ class QrCodeActionTest extends TestCase public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) ->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -74,7 +74,7 @@ class QrCodeActionTest extends TestCase public function aCorrectRequestReturnsTheQrCodeResponse(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) ->willReturn(ShortUrl::createEmpty()) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -100,7 +100,7 @@ class QrCodeActionTest extends TestCase ): void { $this->options->setFromArray(['format' => $defaultFormat]); $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::createEmpty(), ); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -134,7 +134,7 @@ class QrCodeActionTest extends TestCase ): void { $this->options->setFromArray($defaults); $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::createEmpty(), ); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -214,7 +214,7 @@ class QrCodeActionTest extends TestCase ->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize]) ->withAttribute('shortCode', $code); - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::withLongUrl('https://shlink.io'), ); $delegate = $this->prophesize(RequestHandlerInterface::class); diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index b3017fad..cde2b9aa 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -54,7 +54,7 @@ class RedirectActionTest extends TestCase $shortCode = 'abc123'; $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); $shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl( - new ShortUrlIdentifier($shortCode, ''), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn($shortUrl); $track = $this->requestTracker->trackIfApplicable(Argument::cetera())->will(function (): void { }); @@ -74,7 +74,7 @@ class RedirectActionTest extends TestCase public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) ->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotBeCalled(); diff --git a/module/Core/test/Config/BasePathPrefixerTest.php b/module/Core/test/Config/BasePathPrefixerTest.php index f01b9195..36b038c8 100644 --- a/module/Core/test/Config/BasePathPrefixerTest.php +++ b/module/Core/test/Config/BasePathPrefixerTest.php @@ -24,42 +24,16 @@ class BasePathPrefixerTest extends TestCase array $originalConfig, array $expectedRoutes, array $expectedMiddlewares, - string $expectedHostname, ): void { - [ - 'routes' => $routes, - 'middleware_pipeline' => $middlewares, - 'url_shortener' => $urlShortener, - ] = ($this->prefixer)($originalConfig); + ['routes' => $routes, 'middleware_pipeline' => $middlewares] = ($this->prefixer)($originalConfig); self::assertEquals($expectedRoutes, $routes); self::assertEquals($expectedMiddlewares, $middlewares); - self::assertEquals([ - 'domain' => [ - 'hostname' => $expectedHostname, - ], - ], $urlShortener); } public function provideConfig(): iterable { - $urlShortener = [ - 'domain' => [ - 'hostname' => null, - ], - ]; - - yield 'without anything' => [['url_shortener' => $urlShortener], [], [], '']; - yield 'with empty options' => [ - [ - 'routes' => [], - 'middleware_pipeline' => [], - 'url_shortener' => $urlShortener, - ], - [], - [], - '', - ]; + yield 'with empty options' => [['routes' => []], [], []]; yield 'with non-empty options' => [ [ 'routes' => [ @@ -70,11 +44,6 @@ class BasePathPrefixerTest extends TestCase ['with' => 'no_path'], ['path' => '/rest', 'middleware' => []], ], - 'url_shortener' => [ - 'domain' => [ - 'hostname' => 'doma.in', - ], - ], 'router' => ['base_path' => '/foo/bar'], ], [ @@ -85,7 +54,6 @@ class BasePathPrefixerTest extends TestCase ['with' => 'no_path'], ['path' => '/foo/bar/rest', 'middleware' => []], ], - 'doma.in/foo/bar', ]; } } diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php index a7ccbcee..ff4878de 100644 --- a/module/Core/test/Config/EnvVarsTest.php +++ b/module/Core/test/Config/EnvVarsTest.php @@ -6,99 +6,22 @@ namespace ShlinkioTest\Shlink\Core\Config; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\EnvVars; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; +use function Functional\map; use function putenv; class EnvVarsTest extends TestCase { protected function setUp(): void { - putenv(EnvVars::BASE_PATH . '=the_base_path'); - putenv(EnvVars::DB_NAME . '=shlink'); + putenv(EnvVars::BASE_PATH->value . '=the_base_path'); + putenv(EnvVars::DB_NAME->value . '=shlink'); } protected function tearDown(): void { - putenv(EnvVars::BASE_PATH . '='); - putenv(EnvVars::DB_NAME . '='); - } - - /** @test */ - public function casesReturnsTheSameListEveryTime(): void - { - $list = EnvVars::cases(); - self::assertSame($list, EnvVars::cases()); - self::assertSame([ - EnvVars::DELETE_SHORT_URL_THRESHOLD, - EnvVars::DB_DRIVER, - EnvVars::DB_NAME, - EnvVars::DB_USER, - EnvVars::DB_PASSWORD, - EnvVars::DB_HOST, - EnvVars::DB_UNIX_SOCKET, - EnvVars::DB_PORT, - EnvVars::GEOLITE_LICENSE_KEY, - EnvVars::REDIS_SERVERS, - EnvVars::REDIS_SENTINEL_SERVICE, - EnvVars::MERCURE_PUBLIC_HUB_URL, - EnvVars::MERCURE_INTERNAL_HUB_URL, - EnvVars::MERCURE_JWT_SECRET, - EnvVars::DEFAULT_QR_CODE_SIZE, - EnvVars::DEFAULT_QR_CODE_MARGIN, - EnvVars::DEFAULT_QR_CODE_FORMAT, - EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION, - EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, - EnvVars::RABBITMQ_ENABLED, - EnvVars::RABBITMQ_HOST, - EnvVars::RABBITMQ_PORT, - EnvVars::RABBITMQ_USER, - EnvVars::RABBITMQ_PASSWORD, - EnvVars::RABBITMQ_VHOST, - EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT, - EnvVars::DEFAULT_REGULAR_404_REDIRECT, - EnvVars::DEFAULT_BASE_URL_REDIRECT, - EnvVars::REDIRECT_STATUS_CODE, - EnvVars::REDIRECT_CACHE_LIFETIME, - EnvVars::BASE_PATH, - EnvVars::PORT, - EnvVars::TASK_WORKER_NUM, - EnvVars::WEB_WORKER_NUM, - EnvVars::ANONYMIZE_REMOTE_ADDR, - EnvVars::TRACK_ORPHAN_VISITS, - EnvVars::DISABLE_TRACK_PARAM, - EnvVars::DISABLE_TRACKING, - EnvVars::DISABLE_IP_TRACKING, - EnvVars::DISABLE_REFERRER_TRACKING, - EnvVars::DISABLE_UA_TRACKING, - EnvVars::DISABLE_TRACKING_FROM, - EnvVars::DEFAULT_SHORT_CODES_LENGTH, - EnvVars::IS_HTTPS_ENABLED, - EnvVars::DEFAULT_DOMAIN, - EnvVars::AUTO_RESOLVE_TITLES, - EnvVars::REDIRECT_APPEND_EXTRA_PATH, - EnvVars::VISITS_WEBHOOKS, - EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS, - ], $list); - } - - /** - * @test - * @dataProvider provideInvalidEnvVars - */ - public function exceptionIsThrownWhenTryingToLoadInvalidEnvVar(string $envVar): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid env var: "' . $envVar . '"'); - - EnvVars::{$envVar}(); - } - - public function provideInvalidEnvVars(): iterable - { - yield 'foo' => ['foo']; - yield 'bar' => ['bar']; - yield 'invalid' => ['invalid']; + putenv(EnvVars::BASE_PATH->value . '='); + putenv(EnvVars::DB_NAME->value . '='); } /** @@ -112,10 +35,10 @@ class EnvVarsTest extends TestCase public function provideExistingEnvVars(): iterable { - yield 'DB_NAME' => [EnvVars::DB_NAME(), true]; - yield 'BASE_PATH' => [EnvVars::BASE_PATH(), true]; - yield 'DB_DRIVER' => [EnvVars::DB_DRIVER(), false]; - yield 'DEFAULT_REGULAR_404_REDIRECT' => [EnvVars::DEFAULT_REGULAR_404_REDIRECT(), false]; + yield 'DB_NAME' => [EnvVars::DB_NAME, true]; + yield 'BASE_PATH' => [EnvVars::BASE_PATH, true]; + yield 'DB_DRIVER' => [EnvVars::DB_DRIVER, false]; + yield 'DEFAULT_REGULAR_404_REDIRECT' => [EnvVars::DEFAULT_REGULAR_404_REDIRECT, false]; } /** @@ -129,11 +52,18 @@ class EnvVarsTest extends TestCase public function provideEnvVarsValues(): iterable { - yield 'DB_NAME without default' => [EnvVars::DB_NAME(), 'shlink', null]; - yield 'DB_NAME with default' => [EnvVars::DB_NAME(), 'shlink', 'foobar']; - yield 'BASE_PATH without default' => [EnvVars::BASE_PATH(), 'the_base_path', null]; - yield 'BASE_PATH with default' => [EnvVars::BASE_PATH(), 'the_base_path', 'foobar']; - yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER(), null, null]; - yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER(), 'foobar', 'foobar']; + yield 'DB_NAME without default' => [EnvVars::DB_NAME, 'shlink', null]; + yield 'DB_NAME with default' => [EnvVars::DB_NAME, 'shlink', 'foobar']; + yield 'BASE_PATH without default' => [EnvVars::BASE_PATH, 'the_base_path', null]; + yield 'BASE_PATH with default' => [EnvVars::BASE_PATH, 'the_base_path', 'foobar']; + yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null]; + yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar']; + } + + /** @test */ + public function allValuesCanBeListed(): void + { + $expected = map(EnvVars::cases(), static fn (EnvVars $envVar) => $envVar->value); + self::assertEquals(EnvVars::values(), $expected); } } diff --git a/module/Core/test/Config/MultiSegmentSlugProcessorTest.php b/module/Core/test/Config/MultiSegmentSlugProcessorTest.php new file mode 100644 index 00000000..630a5d90 --- /dev/null +++ b/module/Core/test/Config/MultiSegmentSlugProcessorTest.php @@ -0,0 +1,60 @@ +processor = new MultiSegmentSlugProcessor(); + } + + /** + * @test + * @dataProvider provideConfigs + */ + public function parsesRoutesAsExpected(array $config, array $expectedRoutes): void + { + self::assertEquals($expectedRoutes, ($this->processor)($config)['routes'] ?? []); + } + + public function provideConfigs(): iterable + { + yield [[], []]; + yield [['url_shortener' => []], []]; + yield [['url_shortener' => ['multi_segment_slugs_enabled' => false]], []]; + yield [ + [ + 'url_shortener' => ['multi_segment_slugs_enabled' => false], + 'routes' => $routes = [ + ['path' => '/foo'], + ['path' => '/bar/{shortCode}'], + ['path' => '/baz/{shortCode}/foo'], + ], + ], + $routes, + ]; + yield [ + [ + 'url_shortener' => ['multi_segment_slugs_enabled' => true], + 'routes' => [ + ['path' => '/foo'], + ['path' => '/bar/{shortCode}'], + ['path' => '/baz/{shortCode}/foo'], + ], + ], + [ + ['path' => '/foo'], + ['path' => '/bar/{shortCode:.+}'], + ['path' => '/baz/{shortCode:.+}/foo'], + ], + ]; + } +} diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php index 4044446a..33714f88 100644 --- a/module/Core/test/ConfigProviderTest.php +++ b/module/Core/test/ConfigProviderTest.php @@ -22,8 +22,7 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - self::assertCount(5, $config); - self::assertArrayHasKey('routes', $config); + self::assertCount(4, $config); self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('entity_manager', $config); self::assertArrayHasKey('events', $config); diff --git a/module/Core/test/Entity/ShortUrlTest.php b/module/Core/test/Entity/ShortUrlTest.php index d41357cd..b55281ab 100644 --- a/module/Core/test/Entity/ShortUrlTest.php +++ b/module/Core/test/Entity/ShortUrlTest.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; +use Shlinkio\Shlink\Importer\Sources\ImportSource; use function Functional\map; use function range; @@ -63,9 +64,10 @@ class ShortUrlTest extends TestCase public function provideValidShortUrls(): iterable { yield 'no custom slug' => [ShortUrl::createEmpty()]; - yield 'imported with custom slug' => [ - ShortUrl::fromImport(new ImportedShlinkUrl('', '', [], Chronos::now(), null, 'custom-slug', null), true), - ]; + yield 'imported with custom slug' => [ShortUrl::fromImport( + new ImportedShlinkUrl(ImportSource::BITLY, '', [], Chronos::now(), null, 'custom-slug', null), + true, + )]; } /** diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php new file mode 100644 index 00000000..004dfd59 --- /dev/null +++ b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php @@ -0,0 +1,103 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $this->listener = new NotifyNewShortUrlToMercure( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + ); + } + + /** @test */ + public function messageIsLoggedWhenShortUrlIsNotFound(): void + { + $find = $this->em->find(ShortUrl::class, '123')->willReturn(null); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $this->logger->warning( + 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => '123', 'name' => 'Mercure'], + )->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->updatesGenerator->newShortUrlUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function expectedNotificationIsPublished(): void + { + $shortUrl = ShortUrl::withLongUrl(''); + $update = Update::forTopicAndPayload('', []); + + $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); + $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $newUpdate->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate($update)->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function messageIsPrintedIfPublishingFails(): void + { + $shortUrl = ShortUrl::withLongUrl(''); + $update = Update::forTopicAndPayload('', []); + $e = new Exception('Error'); + + $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); + $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); + $publish = $this->helper->publishUpdate($update)->willThrow($e); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $newUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => 'Mercure'], + )->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php similarity index 79% rename from module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php rename to module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index 0b863b69..1ce29d0d 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher\Mercure; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -11,34 +11,35 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; +use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; +use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; -use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure; -use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface; +use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; +use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\Model\Visitor; -use Symfony\Component\Mercure\HubInterface; -use Symfony\Component\Mercure\Update; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; class NotifyVisitToMercureTest extends TestCase { use ProphecyTrait; private NotifyVisitToMercure $listener; - private ObjectProphecy $hub; + private ObjectProphecy $helper; private ObjectProphecy $updatesGenerator; private ObjectProphecy $em; private ObjectProphecy $logger; public function setUp(): void { - $this->hub = $this->prophesize(HubInterface::class); - $this->updatesGenerator = $this->prophesize(MercureUpdatesGeneratorInterface::class); + $this->helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); $this->listener = new NotifyVisitToMercure( - $this->hub->reveal(), + $this->helper->reveal(), $this->updatesGenerator->reveal(), $this->em->reveal(), $this->logger->reveal(), @@ -51,8 +52,8 @@ class NotifyVisitToMercureTest extends TestCase $visitId = '123'; $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); $logWarning = $this->logger->warning( - 'Tried to notify mercure for visit with id "{visitId}", but it does not exist.', - ['visitId' => $visitId], + 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId, 'name' => 'Mercure'], ); $logDebug = $this->logger->debug(Argument::cetera()); $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate( @@ -60,7 +61,7 @@ class NotifyVisitToMercureTest extends TestCase ); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class)); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class)); - $publish = $this->hub->publish(Argument::type(Update::class)); + $publish = $this->helper->publishUpdate(Argument::type(Update::class)); ($this->listener)(new VisitLocated($visitId)); @@ -78,7 +79,7 @@ class NotifyVisitToMercureTest extends TestCase { $visitId = '123'; $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); - $update = new Update('', ''); + $update = Update::forTopicAndPayload('', []); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); $logWarning = $this->logger->warning(Argument::cetera()); @@ -86,7 +87,7 @@ class NotifyVisitToMercureTest extends TestCase $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->hub->publish($update); + $publish = $this->helper->publishUpdate($update); ($this->listener)(new VisitLocated($visitId)); @@ -104,18 +105,19 @@ class NotifyVisitToMercureTest extends TestCase { $visitId = '123'; $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); - $update = new Update('', ''); + $update = Update::forTopicAndPayload('', []); $e = new RuntimeException('Error'); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); $logWarning = $this->logger->warning(Argument::cetera()); - $logDebug = $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ + $logDebug = $this->logger->debug('Error while trying to notify {name} with new visit. {e}', [ 'e' => $e, + 'name' => 'Mercure', ]); $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->hub->publish($update)->willThrow($e); + $publish = $this->helper->publishUpdate($update)->willThrow($e); ($this->listener)(new VisitLocated($visitId)); @@ -135,7 +137,7 @@ class NotifyVisitToMercureTest extends TestCase public function notificationsAreSentForOrphanVisits(Visit $visit): void { $visitId = '123'; - $update = new Update('', ''); + $update = Update::forTopicAndPayload('', []); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); $logWarning = $this->logger->warning(Argument::cetera()); @@ -143,7 +145,7 @@ class NotifyVisitToMercureTest extends TestCase $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->hub->publish($update); + $publish = $this->helper->publishUpdate($update); ($this->listener)(new VisitLocated($visitId)); @@ -160,8 +162,8 @@ class NotifyVisitToMercureTest extends TestCase { $visitor = Visitor::emptyInstance(); - yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)]; - yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)]; - yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)]; + yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; + yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; + yield VisitType::BASE_URL->value => [Visit::forBasePath($visitor)]; } } diff --git a/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php deleted file mode 100644 index 778da889..00000000 --- a/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php +++ /dev/null @@ -1,178 +0,0 @@ -channel = $this->prophesize(AMQPChannel::class); - - $this->connection = $this->prophesize(AMQPStreamConnection::class); - $this->connection->isConnected()->willReturn(false); - $this->connection->channel()->willReturn($this->channel->reveal()); - - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); - - $this->listener = new NotifyVisitToRabbitMq( - $this->connection->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - new OrphanVisitDataTransformer(), - true, - ); - } - - /** @test */ - public function doesNothingWhenTheFeatureIsNotEnabled(): void - { - $listener = new NotifyVisitToRabbitMq( - $this->connection->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - new OrphanVisitDataTransformer(), - false, - ); - - $listener(new VisitLocated('123')); - - $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->connection->isConnected()->shouldNotHaveBeenCalled(); - $this->connection->close()->shouldNotHaveBeenCalled(); - } - - /** @test */ - public function notificationsAreNotSentWhenVisitCannotBeFound(): void - { - $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); - $logWarning = $this->logger->warning( - 'Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', - ['visitId' => $visitId], - ); - - ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->connection->isConnected()->shouldNotHaveBeenCalled(); - $this->connection->close()->shouldNotHaveBeenCalled(); - } - - /** - * @test - * @dataProvider provideVisits - */ - public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void - { - $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); - $argumentWithExpectedChannel = Argument::that(fn (string $channel) => contains($expectedChannels, $channel)); - - ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $this->channel->exchange_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->queue_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->queue_bind( - $argumentWithExpectedChannel, - $argumentWithExpectedChannel, - )->shouldHaveBeenCalledTimes(count($expectedChannels)); - $this->channel->basic_publish(Argument::any(), $argumentWithExpectedChannel)->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->close()->shouldHaveBeenCalledOnce(); - $this->connection->reconnect()->shouldHaveBeenCalledOnce(); - $this->connection->close()->shouldHaveBeenCalledOnce(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - } - - public function provideVisits(): iterable - { - $visitor = Visitor::emptyInstance(); - - yield 'orphan visit' => [Visit::forBasePath($visitor), ['https://shlink.io/new-orphan-visit']]; - yield 'non-orphan visit' => [ - Visit::forValidShortUrl( - ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ - 'longUrl' => 'foo', - 'customSlug' => 'bar', - ])), - $visitor, - ), - ['https://shlink.io/new-visit', 'https://shlink.io/new-visit/bar'], - ]; - } - - /** - * @test - * @dataProvider provideExceptions - */ - public function printsDebugMessageInCaseOfError(Throwable $e): void - { - $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); - $channel = $this->connection->channel()->willThrow($e); - - ($this->listener)(new VisitLocated($visitId)); - - $this->logger->debug( - 'Error while trying to notify RabbitMQ with new visit. {e}', - ['e' => $e], - )->shouldHaveBeenCalledOnce(); - $this->connection->close()->shouldHaveBeenCalledOnce(); - $this->connection->reconnect()->shouldHaveBeenCalledOnce(); - $findVisit->shouldHaveBeenCalledOnce(); - $channel->shouldHaveBeenCalledOnce(); - $this->channel->close()->shouldNotHaveBeenCalled(); - } - - public function provideExceptions(): iterable - { - yield [new RuntimeException('RuntimeException Error')]; - yield [new Exception('Exception Error')]; - yield [new DomainException('DomainException Error')]; - } -} diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php similarity index 59% rename from module/Core/test/Mercure/MercureUpdatesGeneratorTest.php rename to module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 14378b4f..e4b616e8 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -2,27 +2,28 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Mercure; +namespace ShlinkioTest\Shlink\Core\EventDispatcher; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGenerator; +use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGenerator; +use Shlinkio\Shlink\Core\EventDispatcher\Topic; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; -use function Shlinkio\Shlink\Common\json_decode; - -class MercureUpdatesGeneratorTest extends TestCase +class PublishingUpdatesGeneratorTest extends TestCase { - private MercureUpdatesGenerator $generator; + private PublishingUpdatesGenerator $generator; public function setUp(): void { - $this->generator = new MercureUpdatesGenerator( + $this->generator = new PublishingUpdatesGenerator( new ShortUrlDataTransformer(new ShortUrlStringifier([])), new OrphanVisitDataTransformer(), ); @@ -41,9 +42,10 @@ class MercureUpdatesGeneratorTest extends TestCase ])); $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + /** @var Update $update */ $update = $this->generator->{$method}($visit); - self::assertEquals([$expectedTopic], $update->getTopics()); + self::assertEquals($expectedTopic, $update->topic); self::assertEquals([ 'shortUrl' => [ 'shortCode' => $shortUrl->getShortCode(), @@ -69,7 +71,7 @@ class MercureUpdatesGeneratorTest extends TestCase 'date' => $visit->getDate()->toAtomString(), 'potentialBot' => false, ], - ], json_decode($update->getData())); + ], $update->payload); } public function provideMethod(): iterable @@ -86,7 +88,7 @@ class MercureUpdatesGeneratorTest extends TestCase { $update = $this->generator->newOrphanVisitUpdate($orphanVisit); - self::assertEquals(['https://shlink.io/new-orphan-visit'], $update->getTopics()); + self::assertEquals('https://shlink.io/new-orphan-visit', $update->topic); self::assertEquals([ 'visit' => [ 'referer' => '', @@ -95,17 +97,48 @@ class MercureUpdatesGeneratorTest extends TestCase 'date' => $orphanVisit->getDate()->toAtomString(), 'potentialBot' => false, 'visitedUrl' => $orphanVisit->visitedUrl(), - 'type' => $orphanVisit->type(), + 'type' => $orphanVisit->type()->value, ], - ], json_decode($update->getData())); + ], $update->payload); } public function provideOrphanVisits(): iterable { $visitor = Visitor::emptyInstance(); - yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)]; - yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)]; - yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)]; + yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; + yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; + yield VisitType::BASE_URL->value => [Visit::forBasePath($visitor)]; + } + + /** @test */ + public function shortUrlIsProperlySerializedIntoUpdate(): void + { + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'customSlug' => 'foo', + 'longUrl' => '', + 'title' => 'The title', + ])); + + $update = $this->generator->newShortUrlUpdate($shortUrl); + + self::assertEquals(Topic::NEW_SHORT_URL->value, $update->topic); + self::assertEquals(['shortUrl' => [ + 'shortCode' => $shortUrl->getShortCode(), + 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), + 'longUrl' => '', + 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), + 'visitsCount' => 0, + 'tags' => [], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => null, + ], + 'domain' => null, + 'title' => $shortUrl->title(), + 'crawlable' => false, + 'forwardQuery' => true, + ]], $update->payload); } } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php new file mode 100644 index 00000000..9cf44977 --- /dev/null +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -0,0 +1,134 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->options = new RabbitMqOptions(['enabled' => true]); + + $this->listener = new NotifyNewShortUrlToRabbitMq( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + $this->options, + ); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->options->enabled = false; + + ($this->listener)(new ShortUrlCreated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function notificationsAreNotSentWhenShortUrlCannotBeFound(): void + { + $shortUrlId = '123'; + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(null); + $logWarning = $this->logger->warning( + 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => $shortUrlId, 'name' => 'RabbitMQ'], + ); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $find->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function expectedChannelIsNotified(): void + { + $shortUrlId = '123'; + $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( + $update, + ); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $find->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate($update)->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $shortUrlId = '123'; + $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( + $update, + ); + $publish = $this->helper->publishUpdate($update)->willThrow($e); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => 'RabbitMQ'], + )->shouldHaveBeenCalledOnce(); + $find->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } +} diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php new file mode 100644 index 00000000..05ee7568 --- /dev/null +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -0,0 +1,250 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->options = new RabbitMqOptions(['enabled' => true, 'legacy_visits_publishing' => false]); + + $this->listener = new NotifyVisitToRabbitMq( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new OrphanVisitDataTransformer(), + $this->options, + ); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->options->enabled = false; + + ($this->listener)(new VisitLocated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function notificationsAreNotSentWhenVisitCannotBeFound(): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); + $logWarning = $this->logger->warning( + 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId, 'name' => 'RabbitMQ'], + ); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideVisits + */ + public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + each($expectedChannels, function (string $method): void { + $this->updatesGenerator->{$method}(Argument::type(Visit::class))->willReturn( + Update::forTopicAndPayload('', []), + )->shouldBeCalledOnce(); + }); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledTimes( + count($expectedChannels), + ); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideVisits(): iterable + { + $visitor = Visitor::emptyInstance(); + + yield 'orphan visit' => [Visit::forBasePath($visitor), ['newOrphanVisitUpdate']]; + yield 'non-orphan visit' => [ + Visit::forValidShortUrl( + ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo', + 'customSlug' => 'bar', + ])), + $visitor, + ), + ['newShortUrlVisitUpdate', 'newVisitUpdate'], + ]; + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); + $generateUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class))->willReturn( + Update::forTopicAndPayload('', []), + ); + $publish = $this->helper->publishUpdate(Argument::cetera())->willThrow($e); + + ($this->listener)(new VisitLocated($visitId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new visit. {e}', + ['e' => $e, 'name' => 'RabbitMQ'], + )->shouldHaveBeenCalledOnce(); + $findVisit->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } + + /** + * @test + * @dataProvider provideLegacyPayloads + */ + public function expectedPayloadIsPublishedDependingOnConfig( + bool $legacy, + Visit $visit, + callable $assert, + callable $setup, + ): void { + $this->options->legacyVisitsPublishing = $legacy; + + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $setup($this->updatesGenerator); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $assert($this->helper, $this->updatesGenerator); + } + + public function provideLegacyPayloads(): iterable + { + yield 'legacy non-orphan visit' => [ + true, + $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper) use ($visit): void { + $helper->publishUpdate(Argument::that(function (Update $update) use ($visit): bool { + $payload = $update->payload; + Assert::assertEquals($payload, $visit->jsonSerialize()); + Assert::assertArrayNotHasKey('visitedUrl', $payload); + Assert::assertArrayNotHasKey('type', $payload); + Assert::assertArrayNotHasKey('visit', $payload); + Assert::assertArrayNotHasKey('shortUrl', $payload); + + return true; + })); + }, + noop(...), + ]; + yield 'legacy orphan visit' => [ + true, + Visit::forBasePath(Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper): void { + $helper->publishUpdate(Argument::that(function (Update $update): bool { + $payload = $update->payload; + Assert::assertArrayHasKey('visitedUrl', $payload); + Assert::assertArrayHasKey('type', $payload); + + return true; + })); + }, + noop(...), + ]; + yield 'non-legacy non-orphan visit' => [ + false, + Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper): void { + $helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledTimes(2); + }, + function (ObjectProphecy|PublishingUpdatesGeneratorInterface $updatesGenerator): void { + $update = Update::forTopicAndPayload('', []); + $updatesGenerator->newOrphanVisitUpdate(Argument::cetera())->shouldNotBeCalled(); + $updatesGenerator->newVisitUpdate(Argument::cetera())->willReturn($update) + ->shouldBeCalledOnce(); + $updatesGenerator->newShortUrlVisitUpdate(Argument::cetera())->willReturn($update) + ->shouldBeCalledOnce(); + }, + ]; + yield 'non-legacy orphan visit' => [ + false, + Visit::forBasePath(Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper): void { + $helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledOnce(); + }, + function (ObjectProphecy|PublishingUpdatesGeneratorInterface $updatesGenerator): void { + $update = Update::forTopicAndPayload('', []); + $updatesGenerator->newOrphanVisitUpdate(Argument::cetera())->willReturn($update) + ->shouldBeCalledOnce(); + $updatesGenerator->newVisitUpdate(Argument::cetera())->shouldNotBeCalled(); + $updatesGenerator->newShortUrlVisitUpdate(Argument::cetera())->shouldNotBeCalled(); + }, + ]; + } +} diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php new file mode 100644 index 00000000..d5fa8b8c --- /dev/null +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php @@ -0,0 +1,95 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->createListener(false)(new ShortUrlCreated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $shortUrlId = '123'; + $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( + $update, + ); + $publish = $this->helper->publishUpdate($update)->willThrow($e); + + $this->createListener()(new ShortUrlCreated($shortUrlId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => 'Redis pub/sub'], + )->shouldHaveBeenCalledOnce(); + $find->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } + + private function createListener(bool $enabled = true): NotifyNewShortUrlToRedis + { + return new NotifyNewShortUrlToRedis( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + $enabled, + ); + } +} diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php new file mode 100644 index 00000000..3beaa838 --- /dev/null +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -0,0 +1,94 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->createListener(false)(new VisitLocated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); + $generateUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class))->willReturn( + Update::forTopicAndPayload('', []), + ); + $publish = $this->helper->publishUpdate(Argument::cetera())->willThrow($e); + + $this->createListener()(new VisitLocated($visitId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new visit. {e}', + ['e' => $e, 'name' => 'Redis pub/sub'], + )->shouldHaveBeenCalledOnce(); + $findVisit->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } + + private function createListener(bool $enabled = true): NotifyVisitToRedis + { + return new NotifyVisitToRedis( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + $enabled, + ); + } +} diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index ea4e606d..e86a63cb 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -24,7 +24,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase $expectedAdditional['domain'] = $domain; } - $e = ShortUrlNotFoundException::fromNotFound(new ShortUrlIdentifier($shortCode, $domain)); + $e = ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain)); self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index 70662bb1..29111928 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use RuntimeException; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor; @@ -20,7 +21,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; -use Shlinkio\Shlink\Importer\Sources\ImportSources; +use Shlinkio\Shlink\Importer\Params\ImportParams; +use Shlinkio\Shlink\Importer\Sources\ImportSource; use Symfony\Component\Console\Style\StyleInterface; use function count; @@ -32,8 +34,6 @@ class ImportedLinksProcessorTest extends TestCase { use ProphecyTrait; - private const PARAMS = ['import_short_codes' => true, 'source' => ImportSources::BITLY]; - private ImportedLinksProcessor $processor; private ObjectProphecy $em; private ObjectProphecy $shortCodeHelper; @@ -64,9 +64,9 @@ class ImportedLinksProcessorTest extends TestCase public function newUrlsWithNoErrorsAreAllPersisted(): void { $urls = [ - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null), - new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', 'foo'), - new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'bar', [], Chronos::now(), null, 'bar', 'foo'), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz', [], Chronos::now(), null, 'baz', null), ]; $expectedCalls = count($urls); @@ -74,7 +74,7 @@ class ImportedLinksProcessorTest extends TestCase $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, self::PARAMS); + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); $importedUrlExists->shouldHaveBeenCalledTimes($expectedCalls); $ensureUniqueness->shouldHaveBeenCalledTimes($expectedCalls); @@ -82,15 +82,46 @@ class ImportedLinksProcessorTest extends TestCase $this->io->text(Argument::type('string'))->shouldHaveBeenCalledTimes($expectedCalls); } + /** @test */ + public function newUrlsWithErrorsAreSkipped(): void + { + $urls = [ + new ImportedShlinkUrl(ImportSource::BITLY, 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'bar', [], Chronos::now(), null, 'bar', 'foo'), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz', [], Chronos::now(), null, 'baz', null), + ]; + + $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null); + $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $persist = $this->em->persist(Argument::type(ShortUrl::class))->will(function (array $args): void { + /** @var ShortUrl $shortUrl */ + [$shortUrl] = $args; + + if ($shortUrl->getShortCode() === 'baz') { + throw new RuntimeException('Whatever error'); + } + }); + + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); + + $importedUrlExists->shouldHaveBeenCalledTimes(3); + $ensureUniqueness->shouldHaveBeenCalledTimes(3); + $persist->shouldHaveBeenCalledTimes(3); + $this->io->text(Argument::containingString('Imported'))->shouldHaveBeenCalledTimes(2); + $this->io->text( + Argument::containingString('Skipped. Reason: Whatever error'), + )->shouldHaveBeenCalledOnce(); + } + /** @test */ public function alreadyImportedUrlsAreSkipped(): void { $urls = [ - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null), - new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', null), - new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', null), - new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null), - new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'bar', [], Chronos::now(), null, 'bar', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz', [], Chronos::now(), null, 'baz', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz2', [], Chronos::now(), null, 'baz2', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz3', [], Chronos::now(), null, 'baz3', null), ]; $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->will( @@ -98,13 +129,13 @@ class ImportedLinksProcessorTest extends TestCase /** @var ImportedShlinkUrl $url */ [$url] = $args; - return contains(['foo', 'baz2', 'baz3'], $url->longUrl()) ? ShortUrl::fromImport($url, true) : null; + return contains(['foo', 'baz2', 'baz3'], $url->longUrl) ? ShortUrl::fromImport($url, true) : null; }, ); $ensureUniqueness = $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, self::PARAMS); + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); $ensureUniqueness->shouldHaveBeenCalledTimes(2); @@ -117,11 +148,11 @@ class ImportedLinksProcessorTest extends TestCase public function nonUniqueShortCodesAreAskedToUser(): void { $urls = [ - new ImportedShlinkUrl('', 'foo', [], Chronos::now(), null, 'foo', null), - new ImportedShlinkUrl('', 'bar', [], Chronos::now(), null, 'bar', null), - new ImportedShlinkUrl('', 'baz', [], Chronos::now(), null, 'baz', 'foo'), - new ImportedShlinkUrl('', 'baz2', [], Chronos::now(), null, 'baz2', null), - new ImportedShlinkUrl('', 'baz3', [], Chronos::now(), null, 'baz3', 'bar'), + new ImportedShlinkUrl(ImportSource::BITLY, 'foo', [], Chronos::now(), null, 'foo', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'bar', [], Chronos::now(), null, 'bar', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz', [], Chronos::now(), null, 'baz', 'foo'), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz2', [], Chronos::now(), null, 'baz2', null), + new ImportedShlinkUrl(ImportSource::BITLY, 'baz3', [], Chronos::now(), null, 'baz3', 'bar'), ]; $importedUrlExists = $this->repo->findOneByImportedUrl(Argument::cetera())->willReturn(null); @@ -141,7 +172,7 @@ class ImportedLinksProcessorTest extends TestCase }); $persist = $this->em->persist(Argument::type(ShortUrl::class)); - $this->processor->process($this->io->reveal(), $urls, self::PARAMS); + $this->processor->process($this->io->reveal(), $urls, $this->buildParams()); $importedUrlExists->shouldHaveBeenCalledTimes(count($urls)); $failingEnsureUniqueness->shouldHaveBeenCalledTimes(5); @@ -167,7 +198,7 @@ class ImportedLinksProcessorTest extends TestCase $persistUrl = $this->em->persist(Argument::type(ShortUrl::class)); $persistVisits = $this->em->persist(Argument::type(Visit::class)); - $this->processor->process($this->io->reveal(), [$importedUrl], self::PARAMS); + $this->processor->process($this->io->reveal(), [$importedUrl], $this->buildParams()); $findExisting->shouldHaveBeenCalledOnce(); $ensureUniqueness->shouldHaveBeenCalledTimes($foundShortUrl === null ? 1 : 0); @@ -179,7 +210,8 @@ class ImportedLinksProcessorTest extends TestCase public function provideUrlsWithVisits(): iterable { $now = Chronos::now(); - $createImportedUrl = fn (array $visits) => new ImportedShlinkUrl('', 's', [], $now, null, 's', null, $visits); + $createImportedUrl = static fn (array $visits) => + new ImportedShlinkUrl(ImportSource::BITLY, 's', [], $now, null, 's', null, $visits); yield 'new short URL' => [$createImportedUrl([ new ImportedShlinkVisit('', '', $now, null), @@ -214,4 +246,9 @@ class ImportedLinksProcessorTest extends TestCase ])), ]; } + + private function buildParams(): ImportParams + { + return ImportSource::BITLY->toParamsWithCallableMap(['import_short_codes' => static fn () => true]); + } } diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index 1933b3b6..975dc372 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Model; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; @@ -74,12 +75,16 @@ class ShortUrlMetaTest extends TestCase * @test * @dataProvider provideCustomSlugs */ - public function properlyCreatedInstanceReturnsValues(string $customSlug, string $expectedSlug): void - { + public function properlyCreatedInstanceReturnsValues( + string $customSlug, + string $expectedSlug, + bool $multiSegmentEnabled = false, + ): void { $meta = ShortUrlMeta::fromRawData([ 'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug, 'longUrl' => '', + EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, ]); self::assertTrue($meta->hasValidSince()); @@ -103,7 +108,10 @@ class ShortUrlMetaTest extends TestCase yield ['foo bar', 'foo-bar']; yield ['foo bar baz', 'foo-bar-baz']; yield ['foo bar-baz', 'foo-bar-baz']; + yield ['foo/bar/baz', 'foo/bar/baz', true]; + yield ['/foo/bar/baz', 'foo/bar/baz', true]; yield ['foo/bar/baz', 'foo-bar-baz']; + yield ['/foo/bar/baz', '-foo-bar-baz']; yield ['wp-admin.php', 'wp-admin.php']; yield ['UPPER_lower', 'UPPER_lower']; yield ['more~url_special.chars', 'more~url_special.chars']; diff --git a/module/Core/test/Model/VisitorTest.php b/module/Core/test/Model/VisitorTest.php index 50c277c4..92a46a16 100644 --- a/module/Core/test/Model/VisitorTest.php +++ b/module/Core/test/Model/VisitorTest.php @@ -24,9 +24,9 @@ class VisitorTest extends TestCase $visitor = new Visitor(...$params); ['userAgent' => $userAgent, 'referer' => $referer, 'remoteAddress' => $remoteAddress] = $expected; - self::assertEquals($userAgent, $visitor->getUserAgent()); - self::assertEquals($referer, $visitor->getReferer()); - self::assertEquals($remoteAddress, $visitor->getRemoteAddress()); + self::assertEquals($userAgent, $visitor->userAgent); + self::assertEquals($referer, $visitor->referer); + self::assertEquals($remoteAddress, $visitor->remoteAddress); } public function provideParams(): iterable @@ -89,11 +89,11 @@ class VisitorTest extends TestCase ])); self::assertNotSame($visitor, $normalizedVisitor); - self::assertEmpty($normalizedVisitor->getUserAgent()); - self::assertNotEmpty($visitor->getUserAgent()); - self::assertEmpty($normalizedVisitor->getReferer()); - self::assertNotEmpty($visitor->getReferer()); - self::assertNull($normalizedVisitor->getRemoteAddress()); - self::assertNotNull($visitor->getRemoteAddress()); + self::assertEmpty($normalizedVisitor->userAgent); + self::assertNotEmpty($visitor->userAgent); + self::assertEmpty($normalizedVisitor->referer); + self::assertNotEmpty($visitor->referer); + self::assertNull($normalizedVisitor->remoteAddress); + self::assertNotNull($visitor->remoteAddress); } } diff --git a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php index 6c03d7b5..cd4d6193 100644 --- a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php @@ -55,7 +55,7 @@ class DeleteShortUrlServiceTest extends TestCase $this->shortCode, )); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode)); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); } /** @test */ @@ -66,7 +66,7 @@ class DeleteShortUrlServiceTest extends TestCase $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode), true); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode), true); $remove->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); @@ -80,7 +80,7 @@ class DeleteShortUrlServiceTest extends TestCase $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode)); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); $remove->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); @@ -94,7 +94,7 @@ class DeleteShortUrlServiceTest extends TestCase $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode)); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); $remove->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 70857e5e..bdccfa3f 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -91,7 +91,7 @@ class ShortUrlResolverTest extends TestCase )->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode)); + $result = $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); self::assertSame($shortUrl, $result); $findOneByShortCode->shouldHaveBeenCalledOnce(); @@ -116,7 +116,7 @@ class ShortUrlResolverTest extends TestCase $findOneByShortCode->shouldBeCalledOnce(); $getRepo->shouldBeCalledOnce(); - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode)); + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); } public function provideDisabledShortUrls(): iterable diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index b07d4df9..90000423 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -88,7 +88,7 @@ class ShortUrlServiceTest extends TestCase $shortUrl = ShortUrl::withLongUrl($originalLongUrl); $findShortUrl = $this->urlResolver->resolveShortUrl( - new ShortUrlIdentifier('abc123'), + ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), $apiKey, )->willReturn($shortUrl); $flush = $this->em->flush()->willReturn(null); @@ -97,7 +97,11 @@ class ShortUrlServiceTest extends TestCase $shortUrlEdit, ); - $result = $this->service->updateShortUrl(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey); + $result = $this->service->updateShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), + $shortUrlEdit, + $apiKey, + ); self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index 2fb9b017..fbe9b1c4 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -27,6 +28,7 @@ class UrlShortenerTest extends TestCase private ObjectProphecy $em; private ObjectProphecy $titleResolutionHelper; private ObjectProphecy $shortCodeHelper; + private ObjectProphecy $eventDispatcher; public function setUp(): void { @@ -51,11 +53,14 @@ class UrlShortenerTest extends TestCase $this->shortCodeHelper = $this->prophesize(ShortCodeUniquenessHelperInterface::class); $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->urlShortener = new UrlShortener( $this->titleResolutionHelper->reveal(), $this->em->reveal(), new SimpleShortUrlRelationResolver(), $this->shortCodeHelper->reveal(), + $this->eventDispatcher->reveal(), ); } diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index d8997524..4099faea 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -16,6 +16,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; @@ -27,6 +28,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use function str_starts_with; + class ExtraPathRedirectMiddlewareTest extends TestCase { use ProphecyTrait; @@ -65,12 +68,15 @@ class ExtraPathRedirectMiddlewareTest extends TestCase */ public function handlerIsCalledWhenConfigPreventsRedirectWithExtraPath( bool $appendExtraPath, + bool $multiSegmentEnabled, ServerRequestInterface $request, ): void { $this->options->appendExtraPath = $appendExtraPath; + $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; $this->middleware->process($request, $this->handler->reveal()); + $this->handler->handle($request)->shouldHaveBeenCalledOnce(); $this->resolver->resolveEnabledShortUrl(Argument::cetera())->shouldNotHaveBeenCalled(); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -83,65 +89,109 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $buildReq = static fn (?NotFoundType $type): ServerRequestInterface => $baseReq->withAttribute(NotFoundType::class, $type); - yield 'disabled option' => [false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; - yield 'base_url error' => [true, $buildReq(NotFoundType::fromRequest($baseReq, ''))]; + yield 'disabled option' => [false, false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; + yield 'no error type' => [true, false, $buildReq(null)]; + yield 'base_url error' => [true, false, $buildReq(NotFoundType::fromRequest($baseReq, ''))]; yield 'invalid_short_url error' => [ true, - $buildReq(NotFoundType::fromRequest($baseReq, ''))->withAttribute( + false, + $buildReq(NotFoundType::fromRequest($baseReq->withUri(new Uri('/foo'))->withAttribute( RouteResult::class, RouteResult::fromRoute(new Route( - '', + '/foo', $this->prophesize(MiddlewareInterface::class)->reveal(), ['GET'], + RedirectAction::class, )), - ), + ), '')), + ]; + yield 'regular_404 error with multi-segment slugs' => [ + true, + true, + $buildReq(NotFoundType::fromRequest($baseReq->withUri(new Uri('/foo'))->withAttribute( + RouteResult::class, + RouteResult::fromRouteFailure(['GET']), + ), '')), ]; - yield 'no error type' => [true, $buildReq(null)]; } - /** @test */ - public function handlerIsCalledWhenNoShortUrlIsFound(): void - { + /** + * @test + * @dataProvider provideResolves + */ + public function handlerIsCalledWhenNoShortUrlIsFoundAfterExpectedAmountOfIterations( + bool $multiSegmentEnabled, + int $expectedResolveCalls, + ): void { + $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; + $type = $this->prophesize(NotFoundType::class); $type->isRegularNotFound()->willReturn(true); + $type->isInvalidShortUrl()->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) ->withUri(new Uri('/shortCode/bar/baz')); - $resolve = $this->resolver->resolveEnabledShortUrl(Argument::cetera())->willThrow( - ShortUrlNotFoundException::class, - ); + $resolve = $this->resolver->resolveEnabledShortUrl( + Argument::that(fn (ShortUrlIdentifier $identifier) => str_starts_with($identifier->shortCode, 'shortCode')), + )->willThrow(ShortUrlNotFoundException::class); $this->middleware->process($request, $this->handler->reveal()); - $resolve->shouldHaveBeenCalledOnce(); + $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); } - /** @test */ - public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFound(): void - { + /** + * @test + * @dataProvider provideResolves + */ + public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFoundAfterExpectedAmountOfIterations( + bool $multiSegmentEnabled, + int $expectedResolveCalls, + ?string $expectedExtraPath, + ): void { + $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; + $type = $this->prophesize(NotFoundType::class); $type->isRegularNotFound()->willReturn(true); + $type->isInvalidShortUrl()->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) ->withUri(new Uri('https://doma.in/shortCode/bar/baz')); $shortUrl = ShortUrl::withLongUrl(''); - $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('shortCode', 'doma.in'); - - $resolve = $this->resolver->resolveEnabledShortUrl($identifier)->willReturn($shortUrl); - $buildLongUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, [], '/bar/baz')->willReturn( - 'the_built_long_url', + $identifier = Argument::that( + fn (ShortUrlIdentifier $identifier) => str_starts_with($identifier->shortCode, 'shortCode'), ); + + $currentIteration = 1; + $resolve = $this->resolver->resolveEnabledShortUrl($identifier)->will( + function () use ($shortUrl, &$currentIteration, $expectedResolveCalls): ShortUrl { + if ($expectedResolveCalls === $currentIteration) { + return $shortUrl; + } + + $currentIteration++; + throw ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortUrl($shortUrl)); + }, + ); + $buildLongUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, [], $expectedExtraPath) + ->willReturn('the_built_long_url'); $buildResp = $this->redirectResponseHelper->buildRedirectResponse('the_built_long_url')->willReturn( new RedirectResponse(''), ); $this->middleware->process($request, $this->handler->reveal()); - $resolve->shouldHaveBeenCalledOnce(); + $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); $buildLongUrl->shouldHaveBeenCalledOnce(); $buildResp->shouldHaveBeenCalledOnce(); $this->requestTracker->trackIfApplicable($shortUrl, $request)->shouldHaveBeenCalledOnce(); } + + public function provideResolves(): iterable + { + yield [false, 1, '/bar/baz']; + yield [true, 3, null]; + } } diff --git a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 336526b1..2675b04a 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -10,6 +10,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; @@ -49,7 +50,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase $dateRange = $params->dateRange(); $this->repo->findList( - new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange), + new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, TagsMode::ANY, $dateRange), )->shouldBeCalledOnce(); $adapter->getSlice(5, 10); } @@ -75,7 +76,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase $dateRange = $params->dateRange(); $this->repo->countList( - new ShortUrlsCountFiltering($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey), + new ShortUrlsCountFiltering($searchTerm, $tags, TagsMode::ANY, $dateRange, $apiKey), )->shouldBeCalledOnce(); $adapter->getNbResults(); } diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php index 55cb4d86..8aba6598 100644 --- a/module/Core/test/Util/UrlValidatorTest.php +++ b/module/Core/test/Util/UrlValidatorTest.php @@ -107,7 +107,9 @@ class UrlValidatorTest extends TestCase /** @test */ public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabledAndValidationIsEnabled(): void { - $request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle()); + $request = $this->httpClient->request(RequestMethodInterface::METHOD_HEAD, Argument::cetera())->willReturn( + $this->respWithTitle(), + ); $this->options->autoResolveTitles = false; $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); @@ -119,20 +121,57 @@ class UrlValidatorTest extends TestCase /** @test */ public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void { - $request = $this->httpClient->request(Argument::cetera())->willReturn($this->respWithTitle()); + $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( + $this->respWithTitle(), + ); $this->options->autoResolveTitles = true; $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - self::assertEquals('Resolved title', $result); + self::assertEquals('Resolved "title"', $result); + $request->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndReturnedContentTypeIsInvalid(): void + { + $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( + new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream']), + ); + $this->options->autoResolveTitles = true; + + $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + + self::assertNull($result); + $request->shouldHaveBeenCalledOnce(); + } + + /** @test */ + public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndBodyDoesNotContainTitle(): void + { + $request = $this->httpClient->request(RequestMethodInterface::METHOD_GET, Argument::cetera())->willReturn( + new Response($this->createStreamWithContent('No title'), 200, ['Content-Type' => 'text/html']), + ); + $this->options->autoResolveTitles = true; + + $result = $this->urlValidator->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); + + self::assertNull($result); $request->shouldHaveBeenCalledOnce(); } private function respWithTitle(): Response { - $body = new Stream('php://temp', 'wr'); - $body->write(' Resolved title'); + $body = $this->createStreamWithContent(' Resolved "title" '); + return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']); + } - return new Response($body); + private function createStreamWithContent(string $content): Stream + { + $body = new Stream('php://temp', 'wr'); + $body->write($content); + $body->rewind(); + + return $body; } } diff --git a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php index 4c4c00e5..ba1d2767 100644 --- a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php @@ -39,7 +39,7 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $repoCount = $this->repo->countNonOrphanVisits( - new VisitsCountFiltering($this->params->getDateRange(), $this->params->excludeBots(), $this->apiKey), + new VisitsCountFiltering($this->params->dateRange, $this->params->excludeBots, $this->apiKey), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -57,8 +57,8 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $repoFind = $this->repo->findNonOrphanVisits(new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $limit, $offset, diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 0ea91f29..6709c538 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -35,7 +35,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $repoCount = $this->repo->countOrphanVisits( - new VisitsCountFiltering($this->params->getDateRange()), + new VisitsCountFiltering($this->params->dateRange), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -53,7 +53,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $repoFind = $this->repo->findOrphanVisits( - new VisitsListFiltering($this->params->getDateRange(), $this->params->excludeBots(), null, $limit, $offset), + new VisitsListFiltering($this->params->dateRange, $this->params->excludeBots, null, $limit, $offset), )->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); diff --git a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php index 04e17bc6..7d6d04a6 100644 --- a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -36,7 +36,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter(null); $findVisits = $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain(''), - new VisitsListFiltering(DateRange::emptyInstance(), false, null, $limit, $offset), + new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset), )->willReturn([]); for ($i = 0; $i < $count; $i++) { @@ -54,7 +54,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter($apiKey); $countVisits = $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain(''), - new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey), + new VisitsCountFiltering(DateRange::allTime(), false, $apiKey), )->willReturn(3); for ($i = 0; $i < $count; $i++) { @@ -68,7 +68,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase { return new ShortUrlVisitsPaginatorAdapter( $this->repo->reveal(), - new ShortUrlIdentifier(''), + ShortUrlIdentifier::fromShortCodeAndDomain(''), VisitsParams::fromRawData([]), $apiKey, ); diff --git a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 442e7128..32e1bb85 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -35,7 +35,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter(null); $findVisits = $this->repo->findVisitsByTag( 'foo', - new VisitsListFiltering(DateRange::emptyInstance(), false, null, $limit, $offset), + new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset), )->willReturn([]); for ($i = 0; $i < $count; $i++) { @@ -53,7 +53,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter($apiKey); $countVisits = $this->repo->countVisitsByTag( 'foo', - new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey), + new VisitsCountFiltering(DateRange::allTime(), false, $apiKey), )->willReturn(3); for ($i = 0; $i < $count; $i++) { diff --git a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php index c836cd7c..2d2561bd 100644 --- a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php +++ b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; use Shlinkio\Shlink\IpGeolocation\Model\Location; @@ -44,7 +45,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'visitLocation' => null, 'potentialBot' => false, 'visitedUrl' => '', - 'type' => Visit::TYPE_BASE_URL, + 'type' => VisitType::BASE_URL->value, ], ]; yield 'invalid short url visit' => [ @@ -60,7 +61,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'visitLocation' => null, 'potentialBot' => false, 'visitedUrl' => 'https://example.com/foo', - 'type' => Visit::TYPE_INVALID_SHORT_URL, + 'type' => VisitType::INVALID_SHORT_URL->value, ], ]; yield 'regular 404 visit' => [ @@ -78,7 +79,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'visitLocation' => $location, 'potentialBot' => false, 'visitedUrl' => 'https://doma.in/foo/bar', - 'type' => Visit::TYPE_REGULAR_404, + 'type' => VisitType::REGULAR_404->value, ], ]; } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 731697e6..42c821bb 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -10,9 +10,12 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; +use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; @@ -158,6 +161,69 @@ class VisitsStatsHelperTest extends TestCase $getRepo->shouldHaveBeenCalledOnce(); } + /** @test */ + public function throwsExceptionWhenRequestingVisitsForInvalidDomain(): void + { + $domain = 'foo.com'; + $apiKey = ApiKey::create(); + $repo = $this->prophesize(DomainRepository::class); + $domainExists = $repo->domainExists($domain, $apiKey)->willReturn(false); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $this->expectException(DomainNotFoundException::class); + $domainExists->shouldBeCalledOnce(); + $getRepo->shouldBeCalledOnce(); + + $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); + } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function visitsForNonDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + { + $domain = 'foo.com'; + $repo = $this->prophesize(DomainRepository::class); + $domainExists = $repo->domainExists($domain, $apiKey)->willReturn(true); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByDomain($domain, Argument::type(VisitsListFiltering::class))->willReturn($list); + $repo2->countVisitsByDomain($domain, Argument::type(VisitsCountFiltering::class))->willReturn(1); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + + $paginator = $this->helper->visitsForDomain($domain, new VisitsParams(), $apiKey); + + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); + $domainExists->shouldHaveBeenCalledOnce(); + $getRepo->shouldHaveBeenCalledOnce(); + } + + /** + * @test + * @dataProvider provideAdminApiKeys + */ + public function visitsForDefaultDomainAreReturnedAsExpected(?ApiKey $apiKey): void + { + $repo = $this->prophesize(DomainRepository::class); + $domainExists = $repo->domainExists(Argument::cetera()); + $getRepo = $this->em->getRepository(Domain::class)->willReturn($repo->reveal()); + + $list = map(range(0, 1), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance())); + $repo2 = $this->prophesize(VisitRepository::class); + $repo2->findVisitsByDomain('DEFAULT', Argument::type(VisitsListFiltering::class))->willReturn($list); + $repo2->countVisitsByDomain('DEFAULT', Argument::type(VisitsCountFiltering::class))->willReturn(1); + $this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce(); + + $paginator = $this->helper->visitsForDomain('DEFAULT', new VisitsParams(), $apiKey); + + self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); + $domainExists->shouldNotHaveBeenCalled(); + $getRepo->shouldHaveBeenCalledOnce(); + } + /** @test */ public function orphanVisitsAreReturnedAsExpected(): void { diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 5f0d5c05..189180b0 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -6,7 +6,9 @@ namespace Shlinkio\Shlink\Rest; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; +use Mezzio\ProblemDetails\ProblemDetailsResponseFactory; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; +use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options; @@ -32,6 +34,7 @@ return [ Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class, + Action\Visit\DomainVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class, @@ -49,6 +52,7 @@ return [ Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class, + Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class, ], ], @@ -57,10 +61,15 @@ return [ Action\HealthAction::class => ['em', Options\AppOptions::class], Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'], - Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, ShortUrlDataTransformer::class], + Action\ShortUrl\CreateShortUrlAction::class => [ + Service\UrlShortener::class, + ShortUrlDataTransformer::class, + Options\UrlShortenerOptions::class, + ], Action\ShortUrl\SingleStepCreateShortUrlAction::class => [ Service\UrlShortener::class, ShortUrlDataTransformer::class, + Options\UrlShortenerOptions::class, ], Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class], Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class], @@ -70,6 +79,10 @@ return [ ], Action\Visit\ShortUrlVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\TagVisitsAction::class => [Visit\VisitsStatsHelper::class], + Action\Visit\DomainVisitsAction::class => [ + Visit\VisitsStatsHelper::class, + 'config.url_shortener.domain.hostname', + ], Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class], Action\Visit\OrphanVisitsAction::class => [ Visit\VisitsStatsHelper::class, @@ -90,6 +103,10 @@ return [ 'config.url_shortener.default_short_codes_length', ], Middleware\ShortUrl\OverrideDomainMiddleware::class => [DomainService::class], + Middleware\Mercure\NotConfiguredMercureErrorHandler::class => [ + ProblemDetailsResponseFactory::class, + LoggerInterface::class, + ], ], ]; 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 b7787b1a..8df324a4 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 @@ -6,7 +6,9 @@ namespace Shlinkio\Shlink\Rest; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; +use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\ClassMetadata; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Shlinkio\Shlink\Core\determineTableName; @@ -22,11 +24,14 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - $builder->createField('roleName', Types::STRING) - ->columnName('role_name') - ->length(255) - ->nullable(false) - ->build(); + (new FieldBuilder($builder, [ + 'fieldName' => 'roleName', + 'type' => Types::STRING, + 'enumType' => Role::class, + ]))->columnName('role_name') + ->length(255) + ->nullable(false) + ->build(); $builder->createField('meta', Types::JSON) ->columnName('meta') diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php deleted file mode 100644 index 16f83149..00000000 --- a/module/Rest/config/routes.config.php +++ /dev/null @@ -1,52 +0,0 @@ - [ - Action\HealthAction::getRouteDef(), - - // Short URLs - Action\ShortUrl\CreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $dropDomainMiddleware, - $overrideDomainMiddleware, - Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, - ]), - Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $overrideDomainMiddleware, - ]), - Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ListShortUrlsAction::getRouteDef(), - - // Visits - Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), - Action\Visit\TagVisitsAction::getRouteDef(), - Action\Visit\GlobalVisitsAction::getRouteDef(), - Action\Visit\OrphanVisitsAction::getRouteDef(), - Action\Visit\NonOrphanVisitsAction::getRouteDef(), - - // Tags - Action\Tag\ListTagsAction::getRouteDef(), - Action\Tag\TagsStatsAction::getRouteDef(), - Action\Tag\DeleteTagsAction::getRouteDef(), - Action\Tag\UpdateTagAction::getRouteDef(), - - // Domains - Action\Domain\ListDomainsAction::getRouteDef(), - Action\Domain\DomainRedirectsAction::getRouteDef(), - - Action\MercureInfoAction::getRouteDef(), - ], - -]; diff --git a/module/Rest/src/Action/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index da8b6d80..f330bab1 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -8,8 +8,6 @@ use Fig\Http\Message\RequestMethodInterface; use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Server\RequestHandlerInterface; -use function array_merge; - abstract class AbstractRestAction implements RequestHandlerInterface, RequestMethodInterface, StatusCodeInterface { protected const ROUTE_PATH = ''; @@ -19,7 +17,7 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet { return [ 'name' => static::class, - 'middleware' => array_merge($prevMiddleware, [static::class], $postMiddleware), + 'middleware' => [...$prevMiddleware, static::class, ...$postMiddleware], 'path' => static::ROUTE_PATH, 'allowed_methods' => static::ROUTE_ALLOWED_METHODS, ]; diff --git a/module/Rest/src/Action/MercureInfoAction.php b/module/Rest/src/Action/MercureInfoAction.php index d6710357..1454cbbc 100644 --- a/module/Rest/src/Action/MercureInfoAction.php +++ b/module/Rest/src/Action/MercureInfoAction.php @@ -10,7 +10,6 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Rest\Exception\MercureException; -use Throwable; use function sprintf; @@ -32,12 +31,7 @@ class MercureInfoAction extends AbstractRestAction $days = $this->mercureConfig['jwt_days_duration'] ?? 1; $expiresAt = Chronos::now()->addDays($days); - - try { - $jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt); - } catch (Throwable $e) { - throw MercureException::mercureNotConfigured($e); - } + $jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt); return new JsonResponse([ 'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl), diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 90616dc5..f122601b 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -10,14 +10,16 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; abstract class AbstractCreateShortUrlAction extends AbstractRestAction { public function __construct( - private UrlShortenerInterface $urlShortener, - private DataTransformerInterface $transformer, + private readonly UrlShortenerInterface $urlShortener, + private readonly DataTransformerInterface $transformer, + protected readonly UrlShortenerOptions $urlShortenerOptions, ) { } diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index d8b873a6..376c6bec 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; @@ -22,6 +23,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction { $payload = (array) $request->getParsedBody(); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); + $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled(); return ShortUrlMeta::fromRawData($payload); } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 87c21aec..71cf8bf3 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class EditShortUrlAction extends AbstractRestAction { protected const ROUTE_PATH = '/short-urls/{shortCode}'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT]; + protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH]; public function __construct( private ShortUrlServiceInterface $shortUrlService, diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index ab81400c..d52436d2 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -32,7 +32,7 @@ class ListTagsAction extends AbstractRestAction $params = TagsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - if (! $params->withStats()) { + if (! $params->withStats) { return new JsonResponse([ 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)), ]); @@ -41,7 +41,7 @@ class ListTagsAction extends AbstractRestAction // This part is deprecated. To get tags with stats, the /tags/stats endpoint should be used instead $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); $rawTags = $this->serializePaginator($tagsInfo, null, 'stats'); - $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag()); + $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag); return new JsonResponse(['tags' => $rawTags]); } diff --git a/module/Rest/src/Action/Visit/DomainVisitsAction.php b/module/Rest/src/Action/Visit/DomainVisitsAction.php new file mode 100644 index 00000000..b68d971f --- /dev/null +++ b/module/Rest/src/Action/Visit/DomainVisitsAction.php @@ -0,0 +1,48 @@ +resolveDomainParam($request); + $params = VisitsParams::fromRawData($request->getQueryParams()); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $visits = $this->visitsHelper->visitsForDomain($domain, $params, $apiKey); + + return new JsonResponse([ + 'visits' => $this->serializePaginator($visits), + ]); + } + + private function resolveDomainParam(Request $request): string + { + $domainParam = $request->getAttribute('domain', ''); + if ($domainParam === $this->defaultDomain) { + return 'DEFAULT'; + } + + return $domainParam; + } +} diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index 39b5dca1..430221a2 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -8,11 +8,13 @@ use Cake\Chronos\Chronos; final class ApiKeyMeta { + /** + * @param RoleDefinition[] $roleDefinitions + */ private function __construct( - private ?string $name, - private ?Chronos $expirationDate, - /** @var RoleDefinition[] */ - private array $roleDefinitions, + public readonly ?string $name, + public readonly ?Chronos $expirationDate, + public readonly array $roleDefinitions, ) { } @@ -35,22 +37,4 @@ final class ApiKeyMeta { return new self(null, null, $roleDefinitions); } - - public function name(): ?string - { - return $this->name; - } - - public function expirationDate(): ?Chronos - { - return $this->expirationDate; - } - - /** - * @return RoleDefinition[] - */ - public function roleDefinitions(): array - { - return $this->roleDefinitions; - } } diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index fdd4d5cb..63c9b72a 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; final class RoleDefinition { - private function __construct(private string $roleName, private array $meta) + private function __construct(public readonly Role $role, public readonly array $meta) { } @@ -25,14 +25,4 @@ final class RoleDefinition ['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()], ); } - - public function roleName(): string - { - return $this->roleName; - } - - public function meta(): array - { - return $this->meta; - } } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 557abd00..64803969 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -2,6 +2,8 @@ declare(strict_types=1); +// phpcs:disable +// TODO Enable coding style checks again once code sniffer 3.7 is released https://github.com/squizlabs/PHP_CodeSniffer/issues/3474 namespace Shlinkio\Shlink\Rest\ApiKey; use Happyr\DoctrineSpecification\Spec; @@ -12,30 +14,24 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined; use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; -class Role +enum Role: string { - public const AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; - public const DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; - private const ROLE_FRIENDLY_NAMES = [ - self::AUTHORED_SHORT_URLS => 'Author only', - self::DOMAIN_SPECIFIC => 'Domain only', - ]; + case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; + case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification { - return match ($role->name()) { + 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(), }; } public static function toInlinedSpec(ApiKeyRole $role): Specification { - return match ($role->name()) { + 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(), }; } @@ -49,8 +45,11 @@ class Role return $meta['authority'] ?? ''; } - public static function toFriendlyName(string $roleName): string + public static function toFriendlyName(Role $role): string { - return self::ROLE_FRIENDLY_NAMES[$roleName] ?? ''; + return match ($role) { + self::AUTHORED_SHORT_URLS => 'Author only', + self::DOMAIN_SPECIFIC => 'Domain only', + }; } } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 130617d6..6d389038 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest; -use Closure; - use function Functional\first; use function Functional\map; use function Shlinkio\Shlink\Config\loadConfigFromGlob; @@ -17,45 +15,31 @@ class ConfigProvider private const UNVERSIONED_ROUTES_PREFIX = '/rest'; public const UNVERSIONED_HEALTH_ENDPOINT_NAME = 'unversioned_health'; - private Closure $loadConfig; - - public function __construct(?callable $loadConfig = null) - { - $this->loadConfig = Closure::fromCallable($loadConfig ?? fn (string $glob) => loadConfigFromGlob($glob)); - } - public function __invoke(): array { - $config = ($this->loadConfig)(__DIR__ . '/../config/{,*.}config.php'); - return $this->applyRoutesPrefix($config); + return loadConfigFromGlob(__DIR__ . '/../config/{,*.}config.php'); } - private function applyRoutesPrefix(array $config): array + public static function applyRoutesPrefix(array $routes): array { - $routes = $config['routes'] ?? []; - $healthRoute = $this->buildUnversionedHealthRouteFromExistingRoutes($routes); - - $prefixRoute = static function (array $route) { + $healthRoute = self::buildUnversionedHealthRouteFromExistingRoutes($routes); + $prefixedRoutes = map($routes, static function (array $route) { ['path' => $path] = $route; $route['path'] = sprintf('%s%s', self::ROUTES_PREFIX, $path); - return $route; - }; - $prefixedRoutes = map($routes, $prefixRoute); + }); - $config['routes'] = $healthRoute !== null ? [...$prefixedRoutes, $healthRoute] : $prefixedRoutes; - - return $config; + return $healthRoute !== null ? [...$prefixedRoutes, $healthRoute] : $prefixedRoutes; } - private function buildUnversionedHealthRouteFromExistingRoutes(array $routes): ?array + private static function buildUnversionedHealthRouteFromExistingRoutes(array $routes): ?array { $healthRoute = first($routes, fn (array $route) => $route['path'] === '/health'); if ($healthRoute === null) { return null; } - $path = $healthRoute['path']; + ['path' => $path] = $healthRoute; $healthRoute['path'] = sprintf('%s%s', self::UNVERSIONED_ROUTES_PREFIX, $path); $healthRoute['name'] = self::UNVERSIONED_HEALTH_ENDPOINT_NAME; diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 2940bc69..261baee4 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -44,8 +44,8 @@ class ApiKey extends AbstractEntity public static function fromMeta(ApiKeyMeta $meta): self { - $apiKey = new self($meta->name(), $meta->expirationDate()); - foreach ($meta->roleDefinitions() as $roleDefinition) { + $apiKey = new self($meta->name, $meta->expirationDate); + foreach ($meta->roleDefinitions as $roleDefinition) { $apiKey->registerRole($roleDefinition); } @@ -113,45 +113,40 @@ class ApiKey extends AbstractEntity return $this->roles->isEmpty(); } - public function hasRole(string $roleName): bool + public function hasRole(Role $role): bool { - return $this->roles->containsKey($roleName); + return $this->roles->containsKey($role->value); } - public function getRoleMeta(string $roleName): array + public function getRoleMeta(Role $role): array { - /** @var ApiKeyRole|null $role */ - $role = $this->roles->get($roleName); - return $role?->meta() ?? []; + /** @var ApiKeyRole|null $apiKeyRole */ + $apiKeyRole = $this->roles->get($role->value); + return $apiKeyRole?->meta() ?? []; } /** * @template T - * @param callable(string $roleName, array $meta): T $fun + * @param callable(Role $role, array $meta): T $fun * @return T[] */ public function mapRoles(callable $fun): array { - return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues(); + return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role(), $role->meta()))->getValues(); } public function registerRole(RoleDefinition $roleDefinition): void { - $roleName = $roleDefinition->roleName(); - $meta = $roleDefinition->meta(); + $role = $roleDefinition->role; + $meta = $roleDefinition->meta; - if ($this->hasRole($roleName)) { - /** @var ApiKeyRole $role */ - $role = $this->roles->get($roleName); - $role->updateMeta($meta); + if ($this->hasRole($role)) { + /** @var ApiKeyRole $apiKeyRole */ + $apiKeyRole = $this->roles->get($role); + $apiKeyRole->updateMeta($meta); } else { - $role = new ApiKeyRole($roleDefinition->roleName(), $roleDefinition->meta(), $this); - $this->roles[$roleName] = $role; + $apiKeyRole = new ApiKeyRole($roleDefinition->role, $roleDefinition->meta, $this); + $this->roles[$role->value] = $apiKeyRole; } } - - public function removeRole(string $roleName): void - { - $this->roles->remove($roleName); - } } diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 1155c37b..8491cfce 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Entity; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKeyRole extends AbstractEntity { - public function __construct(private string $roleName, private array $meta, private ApiKey $apiKey) + public function __construct(private Role $roleName, private array $meta, private ApiKey $apiKey) { } - public function name(): string + public function role(): Role { return $this->roleName; } diff --git a/module/Rest/src/Exception/MercureException.php b/module/Rest/src/Exception/MercureException.php index 6c318e93..9435cb54 100644 --- a/module/Rest/src/Exception/MercureException.php +++ b/module/Rest/src/Exception/MercureException.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Exception; use Fig\Http\Message\StatusCodeInterface; use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait; use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface; -use Throwable; class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface { @@ -16,9 +15,9 @@ class MercureException extends RuntimeException implements ProblemDetailsExcepti private const TITLE = 'Mercure integration not configured'; private const TYPE = 'MERCURE_NOT_CONFIGURED'; - public static function mercureNotConfigured(?Throwable $prev = null): self + public static function mercureNotConfigured(): self { - $e = new self('This Shlink instance is not integrated with a mercure hub.', 1, $prev); + $e = new self('This Shlink instance is not integrated with a mercure hub.'); $e->detail = $e->getMessage(); $e->title = self::TITLE; diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index 25f1fbe5..7b911817 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -49,7 +49,7 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa throw VerifyAuthenticationException::forInvalidApiKey(); } - return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey())); + return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey)); } public static function apiKeyFromRequest(Request $request): ApiKey diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index b0d63dc7..d6a51a0c 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -11,7 +11,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use function array_merge; use function implode; class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface @@ -45,7 +44,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa ]; // Options requests should always be empty and have a 204 status code - return EmptyResponse::withHeaders(array_merge($response->getHeaders(), $corsHeaders)); + return EmptyResponse::withHeaders([...$response->getHeaders(), ...$corsHeaders]); } private function resolveCorsAllowedMethods(ResponseInterface $response): string diff --git a/module/Rest/src/Middleware/Mercure/NotConfiguredMercureErrorHandler.php b/module/Rest/src/Middleware/Mercure/NotConfiguredMercureErrorHandler.php new file mode 100644 index 00000000..32a714b6 --- /dev/null +++ b/module/Rest/src/Middleware/Mercure/NotConfiguredMercureErrorHandler.php @@ -0,0 +1,34 @@ +handle($request); + } catch (MercureException $e) { + // Throwing this kind of exception makes a big error trace to be logged, for anyone who has decided to not + // use mercure. + // It happens every time the shlink-web-client is opened, so this mitigates the problem by just logging a + // simple warning, and casting the exception to a response on the fly. + $this->logger->warning($e->getMessage()); + return $this->respFactory->createResponseFromThrowable($request, $e); + } + } +} diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php index 2caee4e1..ff74fb79 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class ApiKeyCheckResult { - public function __construct(private ?ApiKey $apiKey = null) + public function __construct(public readonly ?ApiKey $apiKey = null) { } @@ -16,9 +16,4 @@ final class ApiKeyCheckResult { return $this->apiKey !== null && $this->apiKey->isValid(); } - - public function apiKey(): ?ApiKey - { - return $this->apiKey; - } } diff --git a/module/Rest/test-api/Action/DomainVisitsTest.php b/module/Rest/test-api/Action/DomainVisitsTest.php new file mode 100644 index 00000000..b6e29a12 --- /dev/null +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -0,0 +1,68 @@ +callApiWithKey(self::METHOD_GET, sprintf('/domains/%s/visits', $domain), [ + RequestOptions::QUERY => $excludeBots ? ['excludeBots' => true] : [], + ], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); + self::assertArrayHasKey('visits', $payload); + self::assertArrayHasKey('data', $payload['visits']); + self::assertCount($expectedVisitsAmount, $payload['visits']['data']); + } + + public function provideDomains(): iterable + { + yield 'example.com with admin API key' => ['valid_api_key', 'example.com', false, 0]; + yield 'DEFAULT with admin API key' => ['valid_api_key', 'DEFAULT', false, 7]; + yield 'DEFAULT with admin API key and no bots' => ['valid_api_key', 'DEFAULT', true, 6]; + yield 'DEFAULT with domain API key' => ['domain_api_key', 'DEFAULT', false, 0]; + yield 'DEFAULT with author API key' => ['author_api_key', 'DEFAULT', false, 5]; + yield 'DEFAULT with author API key and no bots' => ['author_api_key', 'DEFAULT', true, 4]; + } + + /** + * @test + * @dataProvider provideApiKeysAndTags + */ + public function notFoundErrorIsReturnedForInvalidTags(string $apiKey, string $domain): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/domains/%s/visits', $domain), [], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); + self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); + self::assertEquals('DOMAIN_NOT_FOUND', $payload['type']); + self::assertEquals(sprintf('Domain with authority "%s" could not be found', $domain), $payload['detail']); + self::assertEquals('Domain not found', $payload['title']); + self::assertEquals($domain, $payload['authority']); + } + + public function provideApiKeysAndTags(): iterable + { + yield 'admin API key with invalid domain' => ['valid_api_key', 'invalid_domain.com']; + yield 'domain API key with not-owned valid domain' => ['domain_api_key', 'this_domain_is_detached.com']; + yield 'author API key with valid domain not used in URLs' => ['author_api_key', 'this_domain_is_detached.com']; + } +} diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php index 3efbeacb..b09e2b3b 100644 --- a/module/Rest/test-api/Middleware/CorsTest.php +++ b/module/Rest/test-api/Middleware/CorsTest.php @@ -71,9 +71,9 @@ class CorsTest extends ApiTestCase public function providePreflightEndpoints(): iterable { - yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE']; + yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE']; // TODO This won't work with multi-segment yield 'short URLs route' => ['/short-urls', 'GET,POST']; - yield 'tags route' => ['/tags', 'GET,PUT,DELETE']; + yield 'tags route' => ['/tags', 'GET,DELETE,PUT']; yield 'health route' => ['/health', 'GET']; } } diff --git a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php index 55828368..05212fe7 100644 --- a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -44,9 +44,9 @@ class DomainRedirectsRequestTest extends TestCase $notFound = $request->toNotFoundRedirects($defaults); self::assertEquals($expectedAuthority, $request->authority()); - self::assertEquals($expectedBaseUrlRedirect, $notFound->baseUrlRedirect()); - self::assertEquals($expectedRegular404Redirect, $notFound->regular404Redirect()); - self::assertEquals($expectedInvalidShortUrlRedirect, $notFound->invalidShortUrlRedirect()); + self::assertEquals($expectedBaseUrlRedirect, $notFound->baseUrlRedirect); + self::assertEquals($expectedRegular404Redirect, $notFound->regular404Redirect); + self::assertEquals($expectedInvalidShortUrlRedirect, $notFound->invalidShortUrlRedirect); } public function provideValidData(): iterable diff --git a/module/Rest/test/Action/MercureInfoActionTest.php b/module/Rest/test/Action/MercureInfoActionTest.php index eca4177d..33083c79 100644 --- a/module/Rest/test/Action/MercureInfoActionTest.php +++ b/module/Rest/test/Action/MercureInfoActionTest.php @@ -11,7 +11,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use RuntimeException; use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface; use Shlinkio\Shlink\Rest\Action\MercureInfoAction; use Shlinkio\Shlink\Rest\Exception\MercureException; @@ -49,24 +48,6 @@ class MercureInfoActionTest extends TestCase yield 'host is null' => [['public_hub_url' => null]]; } - /** - * @test - * @dataProvider provideValidConfigs - */ - public function throwsExceptionWhenBuildingTokenFails(array $mercureConfig): void - { - $buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willThrow( - new RuntimeException('Error'), - ); - - $action = new MercureInfoAction($this->provider->reveal(), $mercureConfig); - - $this->expectException(MercureException::class); - $buildToken->shouldBeCalledOnce(); - - $action->handle(ServerRequestFactory::fromGlobals()); - } - public function provideValidConfigs(): iterable { yield 'days not defined' => [['public_hub_url' => 'http://foobar.com']]; diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index ffcd6c62..206b016f 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -34,7 +35,11 @@ class CreateShortUrlActionTest extends TestCase $this->transformer = $this->prophesize(DataTransformerInterface::class); $this->transformer->transform(Argument::type(ShortUrl::class))->willReturn([]); - $this->action = new CreateShortUrlAction($this->urlShortener->reveal(), $this->transformer->reveal()); + $this->action = new CreateShortUrlAction( + $this->urlShortener->reveal(), + $this->transformer->reveal(), + new UrlShortenerOptions(), + ); } /** @test */ diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 04ffb107..19422d9d 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -36,9 +36,11 @@ class ResolveShortUrlActionTest extends TestCase { $shortCode = 'abc123'; $apiKey = ApiKey::create(); - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)->willReturn( - ShortUrl::withLongUrl('http://domain.com/foo/bar'), - )->shouldBeCalledOnce(); + $this->urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + $apiKey, + )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')) + ->shouldBeCalledOnce(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withAttribute(ApiKey::class, $apiKey); $response = $this->action->handle($request); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 8bb1482a..e3fd3e10 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\SingleStepCreateShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -33,6 +34,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase $this->action = new SingleStepCreateShortUrlAction( $this->urlShortener->reveal(), $this->transformer->reveal(), + new UrlShortenerOptions(), ); } diff --git a/module/Rest/test/Action/Visit/DomainVisitsActionTest.php b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php new file mode 100644 index 00000000..84acb1f1 --- /dev/null +++ b/module/Rest/test/Action/Visit/DomainVisitsActionTest.php @@ -0,0 +1,60 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->action = new DomainVisitsAction($this->visitsHelper->reveal(), 'the_default.com'); + } + + /** + * @test + * @dataProvider provideDomainAuthorities + */ + public function providingCorrectDomainReturnsVisits(string $providedDomain, string $expectedDomain): void + { + $apiKey = ApiKey::create(); + $getVisits = $this->visitsHelper->visitsForDomain( + $expectedDomain, + Argument::type(VisitsParams::class), + $apiKey, + )->willReturn(new Paginator(new ArrayAdapter([]))); + + $response = $this->action->handle( + ServerRequestFactory::fromGlobals()->withAttribute('domain', $providedDomain) + ->withAttribute(ApiKey::class, $apiKey), + ); + + self::assertEquals(200, $response->getStatusCode()); + $getVisits->shouldHaveBeenCalledOnce(); + } + + public function provideDomainAuthorities(): iterable + { + yield 'no default domain' => ['foo.com', 'foo.com']; + yield 'default domain' => ['the_default.com', 'DEFAULT']; + yield 'DEFAULT keyword' => ['DEFAULT', 'DEFAULT']; + } +} diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 6e982aec..299c42d1 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -38,7 +38,7 @@ class ShortUrlVisitsActionTest extends TestCase { $shortCode = 'abc123'; $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), Argument::type(VisitsParams::class), Argument::type(ApiKey::class), )->willReturn(new Paginator(new ArrayAdapter([]))) @@ -52,8 +52,8 @@ class ShortUrlVisitsActionTest extends TestCase public function paramsAreReadFromQuery(): void { $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams( - DateRange::withEndDate(Chronos::parse('2016-01-01 00:00:00')), + $this->visitsHelper->visitsForShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsParams( + DateRange::until(Chronos::parse('2016-01-01 00:00:00')), 3, 10, ), Argument::type(ApiKey::class)) diff --git a/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php index 8e6a58ad..ba27a02f 100644 --- a/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php +++ b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php @@ -16,8 +16,8 @@ class RoleDefinitionTest extends TestCase { $definition = RoleDefinition::forAuthoredShortUrls(); - self::assertEquals(Role::AUTHORED_SHORT_URLS, $definition->roleName()); - self::assertEquals([], $definition->meta()); + self::assertEquals(Role::AUTHORED_SHORT_URLS, $definition->role); + self::assertEquals([], $definition->meta); } /** @test */ @@ -26,7 +26,7 @@ class RoleDefinitionTest extends TestCase $domain = Domain::withAuthority('foo.com')->setId('123'); $definition = RoleDefinition::forDomain($domain); - self::assertEquals(Role::DOMAIN_SPECIFIC, $definition->roleName()); - self::assertEquals(['domain_id' => '123', 'authority' => 'foo.com'], $definition->meta()); + self::assertEquals(Role::DOMAIN_SPECIFIC, $definition->role); + self::assertEquals(['domain_id' => '123', 'authority' => 'foo.com'], $definition->meta); } } diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index 7ee23076..f3cc64b2 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -30,7 +30,6 @@ class RoleTest extends TestCase { $apiKey = ApiKey::create(); - yield 'invalid role' => [new ApiKeyRole('invalid', [], $apiKey), Spec::andX()]; yield 'author role' => [ new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), new BelongsToApiKey($apiKey), @@ -54,7 +53,6 @@ class RoleTest extends TestCase { $apiKey = ApiKey::create(); - yield 'invalid role' => [new ApiKeyRole('invalid', [], $apiKey), Spec::andX()]; yield 'author role' => [ new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), Spec::andX(new BelongsToApiKeyInlined($apiKey)), @@ -101,15 +99,14 @@ class RoleTest extends TestCase * @test * @dataProvider provideRoleNames */ - public function getsExpectedRoleFriendlyName(string $roleName, string $expectedFriendlyName): void + public function getsExpectedRoleFriendlyName(Role $roleName, string $expectedFriendlyName): void { self::assertEquals($expectedFriendlyName, Role::toFriendlyName($roleName)); } public function provideRoleNames(): iterable { - yield 'unknown' => ['unknown', '']; - yield Role::AUTHORED_SHORT_URLS => [Role::AUTHORED_SHORT_URLS, 'Author only']; - yield Role::DOMAIN_SPECIFIC => [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, 'Domain only']; } } diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 462947c9..a3f7d0c9 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -22,8 +22,7 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - self::assertCount(5, $config); - self::assertArrayHasKey('routes', $config); + self::assertCount(4, $config); self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('auth', $config); self::assertArrayHasKey('entity_manager', $config); @@ -36,11 +35,7 @@ class ConfigProviderTest extends TestCase */ public function routesAreProperlyPrefixed(array $routes, array $expected): void { - $configProvider = new ConfigProvider(fn () => ['routes' => $routes]); - - $config = $configProvider(); - - self::assertEquals($expected, $config['routes']); + self::assertEquals($expected, ConfigProvider::applyRoutesPrefix($routes)); } public function provideRoutesConfig(): iterable diff --git a/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php b/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php new file mode 100644 index 00000000..138c01f0 --- /dev/null +++ b/module/Rest/test/Middleware/Mercure/NotConfiguredMercureErrorHandlerTest.php @@ -0,0 +1,62 @@ +respFactory = $this->prophesize(ProblemDetailsResponseFactory::class); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->middleware = new NotConfiguredMercureErrorHandler($this->respFactory->reveal(), $this->logger->reveal()); + $this->handler = $this->prophesize(RequestHandlerInterface::class); + } + + /** @test */ + public function requestHandlerIsInvokedWhenNotErrorOccurs(): void + { + $req = ServerRequestFactory::fromGlobals(); + $handle = $this->handler->handle($req)->willReturn(new Response()); + + $this->middleware->process($req, $this->handler->reveal()); + + $handle->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->respFactory->createResponseFromThrowable(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function exceptionIsParsedToResponse(): void + { + $req = ServerRequestFactory::fromGlobals(); + $handle = $this->handler->handle($req)->willThrow(MercureException::mercureNotConfigured()); + $createResp = $this->respFactory->createResponseFromThrowable(Argument::cetera())->willReturn(new Response()); + + $this->middleware->process($req, $this->handler->reveal()); + + $handle->shouldHaveBeenCalledOnce(); + $createResp->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index de17d8bd..aba79036 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -46,7 +46,7 @@ class ApiKeyServiceTest extends TestCase self::assertEquals($date, $key->getExpirationDate()); self::assertEquals($name, $key->name()); foreach ($roles as $roleDefinition) { - self::assertTrue($key->hasRole($roleDefinition->roleName())); + self::assertTrue($key->hasRole($roleDefinition->role)); } } @@ -77,7 +77,7 @@ class ApiKeyServiceTest extends TestCase $result = $this->service->check('12345'); self::assertFalse($result->isValid()); - self::assertSame($invalidKey, $result->apiKey()); + self::assertSame($invalidKey, $result->apiKey); } public function provideInvalidApiKeys(): iterable @@ -100,7 +100,7 @@ class ApiKeyServiceTest extends TestCase $result = $this->service->check('12345'); self::assertTrue($result->isValid()); - self::assertSame($apiKey, $result->apiKey()); + self::assertSame($apiKey, $result->apiKey); } /** @test */