Przeglądaj źródła

Merge pull request #1023 from shlinkio/develop

Release 2.6.0
Alejandro Celaya 3 lat temu
rodzic
commit
3d99fc1708
100 zmienionych plików z 1994 dodań i 682 usunięć
  1. 21 45
      .github/workflows/ci.yml
  2. 38 6
      .github/workflows/publish-release.yml
  3. 1 0
      .gitignore
  4. 48 0
      CHANGELOG.md
  5. 12 7
      CONTRIBUTING.md
  6. 5 4
      Dockerfile
  7. 1 1
      LICENSE
  8. 3 3
      README.md
  9. 1 1
      bin/test/run-api-tests.sh
  10. 24 14
      build.sh
  11. 10 11
      composer.json
  12. 2 0
      config/autoload/installer.global.php
  13. 7 2
      config/autoload/middleware-pipeline.global.php
  14. 3 1
      config/autoload/url-shortener.global.php
  15. 3 1
      config/config.php
  16. 11 25
      data/infra/php.Dockerfile
  17. 18 34
      data/infra/swoole.Dockerfile
  18. 44 0
      data/migrations/Version20210202181026.php
  19. 53 0
      data/migrations/Version20210207100807.php
  20. 3 3
      docker-compose.yml
  21. 2 0
      docker/config/shlink_in_docker.local.php
  22. 35 0
      docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md
  23. 1 0
      docs/adr/README.md
  24. 57 0
      docs/async-api/async-api.json
  25. 23 0
      docs/swagger/definitions/OrphanVisit.json
  26. 6 0
      docs/swagger/definitions/ShortUrl.json
  27. 1 0
      docs/swagger/definitions/Visit.json
  28. 6 2
      docs/swagger/definitions/VisitStats.json
  29. 13 4
      docs/swagger/paths/v1_short-urls.json
  30. 2 1
      docs/swagger/paths/v1_short-urls_shorten.json
  31. 48 6
      docs/swagger/paths/v1_short-urls_{shortCode}.json
  32. 2 1
      docs/swagger/paths/v1_short-urls_{shortCode}_tags.json
  33. 2 1
      docs/swagger/paths/v2_visits.json
  34. 141 0
      docs/swagger/paths/v2_visits_orphan.json
  35. 11 0
      docs/swagger/paths/{shortCode}_qr-code.json
  36. 3 0
      docs/swagger/swagger.json
  37. 13 5
      module/CLI/config/dependencies.config.php
  38. 7 7
      module/CLI/src/Command/Api/GenerateKeyCommand.php
  39. 5 5
      module/CLI/src/Command/Api/ListKeysCommand.php
  40. 51 0
      module/CLI/src/Command/BaseCommand.php
  41. 10 7
      module/CLI/src/Command/Db/AbstractDatabaseCommand.php
  42. 3 3
      module/CLI/src/Command/Db/CreateDatabaseCommand.php
  43. 48 36
      module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php
  44. 18 14
      module/CLI/src/Command/ShortUrl/GetVisitsCommand.php
  45. 46 42
      module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
  46. 32 8
      module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php
  47. 12 2
      module/CLI/src/Command/Util/LockedCommandConfig.php
  48. 1 1
      module/CLI/src/Command/Visit/LocateVisitsCommand.php
  49. 31 3
      module/CLI/src/Exception/GeolocationDbUpdateFailedException.php
  50. 29 3
      module/CLI/src/Util/GeolocationDbUpdater.php
  51. 60 0
      module/CLI/src/Util/ProcessRunner.php
  52. 12 0
      module/CLI/src/Util/ProcessRunnerInterface.php
  53. 1 1
      module/CLI/test/Command/Api/GenerateKeyCommandTest.php
  54. 1 1
      module/CLI/test/Command/Api/ListKeysCommandTest.php
  55. 4 5
      module/CLI/test/Command/Db/CreateDatabaseCommandTest.php
  56. 4 5
      module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php
  57. 20 19
      module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php
  58. 18 16
      module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php
  59. 24 19
      module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php
  60. 1 1
      module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php
  61. 7 5
      module/CLI/test/Command/Visit/LocateVisitsCommandTest.php
  62. 38 10
      module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php
  63. 49 24
      module/CLI/test/Util/GeolocationDbUpdaterTest.php
  64. 106 0
      module/CLI/test/Util/ProcessRunnerTest.php
  65. 34 13
      module/Core/config/dependencies.config.php
  66. 11 0
      module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php
  67. 12 1
      module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php
  68. 6 6
      module/Core/config/event_dispatcher.config.php
  69. 30 0
      module/Core/functions/functions.php
  70. 1 1
      module/Core/src/Action/AbstractTrackingAction.php
  71. 25 10
      module/Core/src/Action/QrCodeAction.php
  72. 1 1
      module/Core/src/Action/RedirectAction.php
  73. 93 78
      module/Core/src/Entity/ShortUrl.php
  74. 49 12
      module/Core/src/Entity/Visit.php
  75. 57 0
      module/Core/src/ErrorHandler/Model/NotFoundType.php
  76. 8 27
      module/Core/src/ErrorHandler/NotFoundRedirectHandler.php
  77. 4 4
      module/Core/src/ErrorHandler/NotFoundTemplateHandler.php
  78. 40 0
      module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php
  79. 27 0
      module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php
  80. 1 1
      module/Core/src/EventDispatcher/Event/UrlVisited.php
  81. 3 3
      module/Core/src/EventDispatcher/LocateVisit.php
  82. 19 2
      module/Core/src/EventDispatcher/NotifyVisitToMercure.php
  83. 6 6
      module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php
  84. 0 5
      module/Core/src/Importer/ImportedLinksProcessor.php
  85. 19 7
      module/Core/src/Mercure/MercureUpdatesGenerator.php
  86. 2 0
      module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php
  87. 0 37
      module/Core/src/Model/CreateShortUrlData.php
  88. 74 18
      module/Core/src/Model/ShortUrlEdit.php
  89. 62 14
      module/Core/src/Model/ShortUrlMeta.php
  90. 4 3
      module/Core/src/Model/ShortUrlsOrdering.php
  91. 11 2
      module/Core/src/Model/Visitor.php
  92. 2 2
      module/Core/src/Model/VisitsParams.php
  93. 33 0
      module/Core/src/Options/UrlShortenerOptions.php
  94. 2 2
      module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php
  95. 30 0
      module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php
  96. 4 4
      module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
  97. 2 2
      module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php
  98. 2 2
      module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php
  99. 7 3
      module/Core/src/Repository/ShortUrlRepository.php
  100. 1 1
      module/Core/src/Repository/ShortUrlRepositoryInterface.php

+ 21 - 45
.github/workflows/ci.yml

@@ -21,7 +21,7 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: none
       - run: composer install --no-interaction --prefer-dist
       - run: composer cs
@@ -39,14 +39,13 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: none
       - run: composer install --no-interaction --prefer-dist
       - run: composer stan
 
   unit-tests:
     runs-on: ubuntu-20.04
-    continue-on-error: ${{ matrix.php-version == '8.0' }}
     strategy:
       matrix:
         php-version: ['7.4', '8.0']
@@ -58,13 +57,10 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: pcov
           ini-values: pcov.directory=module
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - run: composer test:unit:ci
       - uses: actions/upload-artifact@v2
         if: ${{ matrix.php-version == '7.4' }}
@@ -87,13 +83,10 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: pcov
           ini-values: pcov.directory=module
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - run: composer test:db:sqlite:ci
       - uses: actions/upload-artifact@v2
         if: ${{ matrix.php-version == '7.4' }}
@@ -118,12 +111,9 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: none
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - run: composer test:db:mysql
 
   db-tests-maria:
@@ -141,12 +131,9 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: none
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - run: composer test:db:maria
 
   db-tests-postgres:
@@ -164,12 +151,9 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: none
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - run: composer test:db:postgres
 
   db-tests-ms:
@@ -189,19 +173,15 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9, pdo_sqlsrv-5.9.0beta2
+          extensions: swoole-4.6.3, pdo_sqlsrv-5.9.0
           coverage: none
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - name: Create test database
         run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
       - run: composer test:db:ms
 
   api-tests:
     runs-on: ubuntu-20.04
-    continue-on-error: ${{ matrix.php-version == '8.0' }}
     strategy:
       matrix:
         php-version: ['7.4', '8.0']
@@ -209,19 +189,16 @@ jobs:
       - name: Checkout code
         uses: actions/checkout@v2
       - name: Start database server
-        run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db
+        run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres
       - name: Use PHP
         uses: shivammathur/setup-php@v2
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: pcov
           ini-values: pcov.directory=module
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - run: bin/test/run-api-tests.sh
       - uses: actions/upload-artifact@v2
         if: ${{ matrix.php-version == '7.4' }}
@@ -248,13 +225,10 @@ jobs:
         with:
           php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
+          extensions: swoole-4.6.3
           coverage: pcov
           ini-values: pcov.directory=module
-      - if: ${{ matrix.php-version == '8.0' }}
-        run: composer install --no-interaction --prefer-dist --ignore-platform-req=php
-      - if: ${{ matrix.php-version != '8.0' }}
-        run: composer install --no-interaction --prefer-dist
+      - run: composer install --no-interaction --prefer-dist
       - uses: actions/download-artifact@v2
         with:
           path: build
@@ -309,6 +283,8 @@ jobs:
     steps:
       - name: Checkout code
         uses: actions/checkout@v2
+        with:
+          fetch-depth: 100
       - uses: marceloprado/has-changed-path@v1
         id: changed-dockerfile
         with:

+ 38 - 6
.github/workflows/publish-release.yml

@@ -7,18 +7,38 @@ on:
 
 jobs:
   build:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-20.04
+    strategy:
+      matrix:
+        php-version: ['7.4', '8.0']
+        swoole: ['yes', 'no']
     steps:
       - name: Checkout code
         uses: actions/checkout@v2
-      - name: Use PHP 7.4
+      - name: Use PHP
         uses: shivammathur/setup-php@v2
         with:
-          php-version: '7.4' # Publish release with lowest supported PHP version
+          php-version: ${{ matrix.php-version }}
           tools: composer
-          extensions: swoole-4.5.9
-      - name: Generate release assets
+          extensions: swoole-4.6.3
+      - if: ${{ matrix.swoole == 'yes' }}
         run: ./build.sh ${GITHUB_REF#refs/tags/v}
+      - if: ${{ matrix.swoole == 'no' }}
+        run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole
+      - uses: actions/upload-artifact@v2
+        with:
+          name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}
+          path: build
+
+  publish:
+    needs: ['build']
+    runs-on: ubuntu-20.04
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v2
+      - uses: actions/download-artifact@v2
+        with:
+          path: build
       - name: Publish release with assets
         uses: docker://antonyurchenko/git-release:latest
         env:
@@ -27,4 +47,16 @@ jobs:
           ALLOW_EMPTY_CHANGELOG: "true"
         with:
           args: |
-            build/shlink_*_dist.zip
+            build/*/shlink*_dist.zip
+
+  delete-artifacts:
+    needs: ['publish']
+    runs-on: ubuntu-20.04
+    strategy:
+      matrix:
+        php-version: [ '7.4', '8.0' ]
+        swoole: [ 'yes', 'no' ]
+    steps:
+      - uses: geekyeggo/delete-artifact@v1
+        with:
+          name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }}

+ 1 - 0
.gitignore

@@ -9,5 +9,6 @@ data/shlink-tests.db
 data/GeoLite2-City.mmdb
 data/GeoLite2-City.mmdb.*
 docs/swagger-ui*
+docs/mercure.html
 docker-compose.override.yml
 .phpunit.result.cache

+ 48 - 0
CHANGELOG.md

@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
 
+## [2.6.0] - 2021-02-13
+### Added
+* [#856](https://github.com/shlinkio/shlink/issues/856) Added PHP 8.0 support.
+* [#941](https://github.com/shlinkio/shlink/issues/941) Added support to provide a title for every short URL.
+
+    The title can also be automatically resolved from the long URL, when no title was explicitly provided, but this option needs to be opted in.
+
+* [#913](https://github.com/shlinkio/shlink/issues/913) Added support to import short URLs from a standard CSV file.
+
+    The file requires the `Long URL` and `Short code` columns, and it also accepts the optional `title`, `domain` and `tags` columns.
+
+* [#1000](https://github.com/shlinkio/shlink/issues/1000) Added support to provide a `margin` query param when generating some URL's QR code.
+* [#675](https://github.com/shlinkio/shlink/issues/675) Added ability to track visits to the base URL, invalid short URLs or any other "not found" URL, as known as orphan visits.
+
+    This behavior is enabled by default, but you can opt out via env vars or config options.
+
+    This new orphan visits can be consumed in these ways:
+
+      * The `https://shlink.io/new-orphan-visit` mercure topic, which gets notified when an orphan visit occurs.
+      * The `GET /visits/orphan` REST endpoint, which behaves like the short URL visits and tags visits endpoints, but returns only orphan visits.
+
+### Changed
+* [#977](https://github.com/shlinkio/shlink/issues/977) Migrated from `laminas/laminas-paginator` to `pagerfanta/core` to handle pagination.
+* [#986](https://github.com/shlinkio/shlink/issues/986) Updated official docker image to use PHP 8.
+* [#1010](https://github.com/shlinkio/shlink/issues/1010) Increased timeout for database commands to 10 minutes.
+* [#874](https://github.com/shlinkio/shlink/issues/874) Changed how dist files are generated. Now there will be two for every supported PHP version, with and without support for swoole.
+
+    The dist files will have been built under the same PHP version they are meant to be run under, ensuring resolved dependencies are the proper ones.
+
+### Deprecated
+* [#959](https://github.com/shlinkio/shlink/issues/959) Deprecated all command flags using camelCase format (like `--expirationDate`), adding kebab-case replacements for all of them (like `--expiration-date`).
+
+    All the existing camelCase flags will continue working for now, but will be removed in Shlink 3.0.0
+
+* [#862](https://github.com/shlinkio/shlink/issues/862) Deprecated the endpoint to edit tags for a short URL (`PUT /short-urls/{shortCode}/tags`).
+
+    The short URL edition endpoint (`PATCH /short-urls/{shortCode}`) now supports setting the tags too. Use it instead.
+
+### Removed
+* *Nothing*
+
+### Fixed
+* [#988](https://github.com/shlinkio/shlink/issues/988) Fixed serving zero-byte static files in apache and apache-compatible web servers.
+* [#990](https://github.com/shlinkio/shlink/issues/990) Fixed short URLs not properly composed in REST API endpoints when both custom domain and custom base path are used.
+* [#1002](https://github.com/shlinkio/shlink/issues/1002) Fixed weird behavior in which GeoLite2 metadata's `buildEpoch` is parsed as string instead of int.
+* [#851](https://github.com/shlinkio/shlink/issues/851) Fixed error when trying to schedule swoole tasks in ARM architectures (like raspberry).
+
+
 ## [2.5.2] - 2021-01-24
 ### Added
 * [#965](https://github.com/shlinkio/shlink/issues/965) Added docs section for Architectural Decision Records, including the one for API key roles.

+ 12 - 7
CONTRIBUTING.md

@@ -33,7 +33,7 @@ Then you will have to follow these steps:
 
 Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through swoole.
 
-> Note: The `indocker` shell script is a helper used to run commands inside the main docker container.
+> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container.
 
 ## Project structure
 
@@ -88,9 +88,9 @@ In order to ensure stability and no regressions are introduced while developing
 
 * **Unit tests**: These are the simplest to run, and usually test individual pieces of code, replacing any external dependency by mocks.
 
-    The code coverage of unit tests is pretty high, and only entity repositories are excluded because of their nature.
+    The code coverage of unit tests is pretty high, and only components which work closer to the database, like entity repositories, are excluded because of their nature.
 
-* **Database tests**: These are integration tests that run against a real database, and only cover entity repositories.
+* **Database tests**: These are integration tests that run against a real database, and only cover components which work closer to the database.
 
     Its purpose is to verify all the database queries behave as expected and return what's expected.
 
@@ -98,7 +98,7 @@ In order to ensure stability and no regressions are introduced while developing
 
 * **API tests**: These are E2E tests that spin up an instance of the app and test it from the outside, by interacting with the REST API.
 
-    These are the best tests to catch regressions, and to verify everything interacts as expected.
+    These are the best tests to catch regressions, and to verify everything behaves as expected.
 
     They use MySQL as the database engine, and include some fixtures that ensure the same data exists at the beginning of the execution.
 
@@ -114,13 +114,14 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
 * Run `./indocker composer test:unit` to run the unit tests.
 * Run `./indocker composer test:db` to run the database integration tests.
 
-    This command runs the same test suite against all supported database engines. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
+    This command runs the same test suite against all supported database engines in parallel. If you just want to run one of them, you can add one of `:sqlite`, `:mysql`, `:maria`, `:postgres`, `:mssql` at the end of the command.
     
     For example, `test:db:postgres`.
 
 * Run `./indocker composer test:api` to run API E2E tests. For these, the MySQL database engine is used.
 * Run `./indocker composer infect:test` ti run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/).
 * Run `./indocker composer ci` to run all previous commands together. 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).
 >
@@ -130,11 +131,15 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed,
 
 ## Pull request process
 
-In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
+**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.
+
+This is important because any contribution needs to be discussed first. Maybe there's someone else already working on something similar, or there are other considerations to have in mind.
+
+Once everything is clear, to provide a pull request to this project, you should always start by creating a new branch, where you will make all desired changes.
 
 The base branch should always be `develop`, and the target branch for the pull request should also be `develop`.
 
-Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci`, or wait for the build to be run automatically after the pull request is created.
+Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually by running `./indocker composer ci:parallel`, or wait for the build to be run automatically after the pull request is created.
 
 ## Architectural Decision Records
 

+ 5 - 4
Dockerfile

@@ -1,8 +1,9 @@
-FROM php:7.4.11-alpine3.12 as base
+FROM php:8.0.2-alpine3.13 as base
 
-ARG SHLINK_VERSION=2.4.0
+ARG SHLINK_VERSION=2.5.2
 ENV SHLINK_VERSION ${SHLINK_VERSION}
-ENV SWOOLE_VERSION 4.5.9
+ENV SWOOLE_VERSION 4.6.3
+ENV PDO_SQLSRV_VERSION 5.9.0
 ENV LC_ALL "C"
 
 WORKDIR /etc/shlink
@@ -32,7 +33,7 @@ RUN if [ $(uname -m) == "x86_64" ]; then \
       wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
       apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
       apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
-      pecl install pdo_sqlsrv && \
+      pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
       docker-php-ext-enable pdo_sqlsrv && \
       apk del .phpize-deps && \
       rm msodbcsql17_17.5.1.1-1_amd64.apk ; \

+ 1 - 1
LICENSE

@@ -1,6 +1,6 @@
 The MIT License (MIT)
 
-Copyright (c) 2016-2020 Alejandro Celaya
+Copyright (c) 2016-2021 Alejandro Celaya
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal

+ 3 - 3
README.md

@@ -47,7 +47,7 @@ In order to run Shlink, you will need a built version of the project. There are
 
     The easiest way to install shlink is by using one of the pre-bundled distributable packages.
 
-    Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink_x.x.x_dist.zip` file you will find there.
+    Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without swoole integration.
 
     Finally, decompress the file in the location of your choice.
 
@@ -57,9 +57,9 @@ In order to run Shlink, you will need a built version of the project. There are
 
     * Clone the project with git (`git clone https://github.com/shlinkio/shlink.git`), or download it by clicking the **Clone or download** green button.
     * Download the [Composer](https://getcomposer.org/download/) PHP package manager inside the project folder.
-    * Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is only used for the generated dist file).
+    * Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is used as part of the generated dist file name, and to set the value returned when running `shlink -V` from the command line).
 
-    After that, you will have a `shlink_x.x.x_dist.zip` dist file inside the `build` directory, that you need to decompress in the location fo your choice.
+    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.
 

+ 1 - 1
bin/test/run-api-tests.sh

@@ -1,6 +1,6 @@
 #!/usr/bin/env sh
 export APP_ENV=test
-export DB_DRIVER=mysql
+export DB_DRIVER=postgres
 export TEST_ENV=api
 
 # Try to stop server just in case it hanged in last execution

+ 24 - 14
build.sh

@@ -1,35 +1,45 @@
 #!/usr/bin/env bash
 set -e
 
-if [[ "$#" -ne 1 ]]; then
+if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then
   echo "Usage:" >&2
-  echo "   $0 {version}" >&2
+  echo "   $0 {version} [--no-swoole]" >&2
   exit 1
 fi
 
 version=$1
-builtcontent="./build/shlink_${version}_dist"
+noSwoole=$2
+phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')
+[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_swoole"
+distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist"
+builtContent="./build/${distId}"
 projectdir=$(pwd)
 [[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer'
 
 # Copy project content to temp dir
 echo 'Copying project files...'
-rm -rf "${builtcontent}"
-mkdir -p "${builtcontent}"
-rsync -av * "${builtcontent}" \
+rm -rf "${builtContent}"
+mkdir -p "${builtContent}"
+rsync -av * "${builtContent}" \
     --exclude=*docker* \
     --exclude=Dockerfile \
     --include=.htaccess \
     --exclude-from=./.dockerignore
-cd "${builtcontent}"
+cd "${builtContent}"
 
 # Install dependencies
 echo "Installing dependencies with $composerBin..."
+composerFlags="--optimize-autoloader --no-progress --no-interaction"
 ${composerBin} self-update
-${composerBin} install --no-dev --optimize-autoloader --prefer-dist --no-progress --no-interaction
-
-# Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0)
-cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin"
+${composerBin} install --no-dev --prefer-dist $composerFlags
+
+if [[ $noSwoole ]]; then
+  # If generating a dist not for swoole, uninstall mezzio-swoole
+  ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags
+else
+  # Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0)
+  cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin"
+fi
 
 # Delete development files
 echo 'Deleting dev files...'
@@ -41,9 +51,9 @@ sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
 # Compressing file
 echo 'Compressing files...'
 cd "${projectdir}"/build
-rm -f ./shlink_${version}_dist.zip
-zip -ry ./shlink_${version}_dist.zip ./shlink_${version}_dist
+rm -f ./${distId}.zip
+zip -ry ./${distId}.zip ./${distId}
 cd "${projectdir}"
-rm -rf "${builtcontent}"
+rm -rf "${builtContent}"
 
 echo 'Done!'

+ 10 - 11
composer.json

@@ -12,7 +12,7 @@
         }
     ],
     "require": {
-        "php": "^7.4",
+        "php": "^7.4 || ^8.0",
         "ext-json": "*",
         "ext-pdo": "*",
         "akrabat/ip-address-middleware": "^2.0",
@@ -21,7 +21,7 @@
         "doctrine/cache": "^1.9",
         "doctrine/migrations": "^3.0.2",
         "doctrine/orm": "^2.8",
-        "endroid/qr-code": "^3.6",
+        "endroid/qr-code": "dev-master#0f1613a as 3.10",
         "geoip2/geoip2": "^2.9",
         "guzzlehttp/guzzle": "^7.0",
         "happyr/doctrine-specification": "2.0.x-dev#cb116d3 as 2.0",
@@ -29,29 +29,28 @@
         "laminas/laminas-config-aggregator": "^1.1",
         "laminas/laminas-diactoros": "^2.1.3",
         "laminas/laminas-inputfilter": "^2.10",
-        "laminas/laminas-paginator": "^2.8",
         "laminas/laminas-servicemanager": "^3.6",
         "laminas/laminas-stdlib": "^3.2",
         "lcobucci/jwt": "^4.0",
         "league/uri": "^6.2",
         "lstrojny/functional-php": "^1.15",
-        "mezzio/mezzio": "^3.2",
+        "mezzio/mezzio": "^3.3",
         "mezzio/mezzio-fastroute": "^3.1",
-        "mezzio/mezzio-helpers": "^5.3",
-        "mezzio/mezzio-problem-details": "^1.1",
+        "mezzio/mezzio-problem-details": "^1.3",
         "mezzio/mezzio-swoole": "^3.1",
         "monolog/monolog": "^2.0",
         "nikolaposa/monolog-factory": "^3.1",
         "ocramius/proxy-manager": "^2.11",
+        "pagerfanta/core": "^2.5",
         "php-middleware/request-id": "^4.1",
         "predis/predis": "^1.1",
         "pugx/shortid-php": "^0.7",
         "ramsey/uuid": "^3.9",
-        "shlinkio/shlink-common": "^3.4",
+        "shlinkio/shlink-common": "^3.5",
         "shlinkio/shlink-config": "^1.0",
-        "shlinkio/shlink-event-dispatcher": "^2.0",
-        "shlinkio/shlink-importer": "^2.1",
-        "shlinkio/shlink-installer": "^5.3",
+        "shlinkio/shlink-event-dispatcher": "^2.1",
+        "shlinkio/shlink-importer": "^2.2",
+        "shlinkio/shlink-installer": "^5.4",
         "shlinkio/shlink-ip-geolocation": "^1.5",
         "symfony/console": "^5.1",
         "symfony/filesystem": "^5.1",
@@ -64,7 +63,7 @@
         "devster/ubench": "^2.1",
         "dms/phpunit-arraysubset-asserts": "^0.2.1",
         "eaglewu/swoole-ide-helper": "dev-master",
-        "infection/infection": "^0.20.2",
+        "infection/infection": "^0.21.0",
         "phpspec/prophecy-phpunit": "^2.0",
         "phpstan/phpstan": "^0.12.64",
         "phpunit/php-code-coverage": "^9.2",

+ 2 - 0
config/autoload/installer.global.php

@@ -40,6 +40,8 @@ return [
             Option\UrlShortener\IpAnonymizationConfigOption::class,
             Option\UrlShortener\RedirectStatusCodeConfigOption::class,
             Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
+            Option\UrlShortener\AutoResolveTitlesConfigOption::class,
+            Option\UrlShortener\OrphanVisitsTrackingConfigOption::class,
         ],
 
         'installation_commands' => [

+ 7 - 2
config/autoload/middleware-pipeline.global.php

@@ -5,17 +5,18 @@ declare(strict_types=1);
 namespace Shlinkio\Shlink;
 
 use Laminas\Stratigility\Middleware\ErrorHandler;
-use Mezzio\Helper;
 use Mezzio\ProblemDetails;
 use Mezzio\Router;
 use PhpMiddleware\RequestId\RequestIdMiddleware;
+use RKA\Middleware\IpAddress;
+use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware;
 
 return [
 
     'middleware_pipeline' => [
         'error-handler' => [
             'middleware' => [
-                Helper\ContentLengthMiddleware::class,
+                ContentLengthMiddleware::class,
                 ErrorHandler::class,
             ],
         ],
@@ -64,6 +65,10 @@ return [
         ],
         'not-found' => [
             'middleware' => [
+                // This middleware is in front of tracking actions explicitly. Putting here for orphan visits tracking
+                IpAddress::class,
+                Core\ErrorHandler\NotFoundTypeResolverMiddleware::class,
+                Core\ErrorHandler\NotFoundTrackerMiddleware::class,
                 Core\ErrorHandler\NotFoundRedirectHandler::class,
                 Core\ErrorHandler\NotFoundTemplateHandler::class,
             ],

+ 3 - 1
config/autoload/url-shortener.global.php

@@ -13,12 +13,14 @@ return [
             'schema' => 'https',
             'hostname' => '',
         ],
-        'validate_url' => false,
+        'validate_url' => false, // Deprecated
         'anonymize_remote_addr' => true,
         'visits_webhooks' => [],
         'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
         'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
         'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
+        'auto_resolve_titles' => false,
+        'track_orphan_visits' => true,
     ],
 
 ];

+ 3 - 1
config/config.php

@@ -8,14 +8,16 @@ use Laminas\ConfigAggregator;
 use Laminas\Diactoros;
 use Mezzio;
 use Mezzio\ProblemDetails;
+use Mezzio\Swoole\ConfigProvider as SwooleConfigProvider;
 
+use function class_exists;
 use function Shlinkio\Shlink\Common\env;
 
 return (new ConfigAggregator\ConfigAggregator([
     Mezzio\ConfigProvider::class,
     Mezzio\Router\ConfigProvider::class,
     Mezzio\Router\FastRouteRouter\ConfigProvider::class,
-    Mezzio\Swoole\ConfigProvider::class,
+    class_exists(SwooleConfigProvider::class) ? SwooleConfigProvider::class : new ConfigAggregator\ArrayProvider([]),
     ProblemDetails\ConfigProvider::class,
     Diactoros\ConfigProvider::class,
     Common\ConfigProvider::class,

+ 11 - 25
data/infra/php.Dockerfile

@@ -1,8 +1,8 @@
-FROM php:7.4.11-fpm-alpine3.12
+FROM php:8.0.2-fpm-alpine3.13
 MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
 
-ENV APCU_VERSION 5.1.18
-ENV APCU_BC_VERSION 1.0.5
+ENV APCU_VERSION 5.1.19
+ENV PDO_SQLSRV_VERSION 5.9.0
 
 RUN apk update
 
@@ -35,33 +35,19 @@ RUN docker-php-ext-install gmp
 
 # Install APCu extension
 ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
-RUN mkdir -p /usr/src/php/ext/apcu\
-  && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
-# configure and install
-RUN docker-php-ext-configure apcu\
-  && docker-php-ext-install apcu
-# cleanup
-RUN rm /tmp/apcu.tar.gz
-
-# Install APCu-BC extension
-ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
-RUN mkdir -p /usr/src/php/ext/apcu-bc\
-  && tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
-# configure and install
-RUN docker-php-ext-configure apcu-bc\
-  && docker-php-ext-install apcu-bc
-# cleanup
-RUN rm /tmp/apcu_bc.tar.gz
-
-# Load APCU.ini before APC.ini
-RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
-RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
+RUN mkdir -p /usr/src/php/ext/apcu \
+  && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
+  && docker-php-ext-configure apcu \
+  && docker-php-ext-install apcu \
+  && rm /tmp/apcu.tar.gz \
+  && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
+  && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
 
 # Install pcov and sqlsrv driver
 RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
     apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
     apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
-    pecl install pdo_sqlsrv pcov && \
+    pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
     docker-php-ext-enable pdo_sqlsrv pcov && \
     apk del .phpize-deps && \
     rm msodbcsql17_17.5.1.1-1_amd64.apk

+ 18 - 34
data/infra/swoole.Dockerfile

@@ -1,10 +1,10 @@
-FROM php:7.4.11-alpine3.12
+FROM php:8.0.2-alpine3.13
 MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
 
-ENV APCU_VERSION 5.1.18
-ENV APCU_BC_VERSION 1.0.5
-ENV INOTIFY_VERSION 2.0.0
-ENV SWOOLE_VERSION 4.5.9
+ENV APCU_VERSION 5.1.19
+ENV PDO_SQLSRV_VERSION 5.9.0
+ENV INOTIFY_VERSION 3.0.0
+ENV SWOOLE_VERSION 4.6.3
 
 RUN apk update
 
@@ -37,43 +37,27 @@ RUN docker-php-ext-install gmp
 
 # Install APCu extension
 ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz
-RUN mkdir -p /usr/src/php/ext/apcu\
-  && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1
-# configure and install
-RUN docker-php-ext-configure apcu\
-  && docker-php-ext-install apcu
-# cleanup
-RUN rm /tmp/apcu.tar.gz
-
-# Install APCu-BC extension
-ADD https://pecl.php.net/get/apcu_bc-$APCU_BC_VERSION.tgz /tmp/apcu_bc.tar.gz
-RUN mkdir -p /usr/src/php/ext/apcu-bc\
-  && tar xf /tmp/apcu_bc.tar.gz -C /usr/src/php/ext/apcu-bc --strip-components=1
-# configure and install
-RUN docker-php-ext-configure apcu-bc\
-  && docker-php-ext-install apcu-bc
-# cleanup
-RUN rm /tmp/apcu_bc.tar.gz
-
-# Load APCU.ini before APC.ini
-RUN rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini
-RUN echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
+RUN mkdir -p /usr/src/php/ext/apcu \
+  && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \
+  && docker-php-ext-configure apcu \
+  && docker-php-ext-install apcu \
+  && rm /tmp/apcu.tar.gz \
+  && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \
+  && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini
 
 # Install inotify extension
 ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz
-RUN mkdir -p /usr/src/php/ext/inotify\
-  && tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1
-# configure and install
-RUN docker-php-ext-configure inotify\
-  && docker-php-ext-install inotify
-# cleanup
-RUN rm /tmp/inotify.tar.gz
+RUN mkdir -p /usr/src/php/ext/inotify \
+  && tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \
+  && docker-php-ext-configure inotify \
+  && docker-php-ext-install inotify \
+  && rm /tmp/inotify.tar.gz
 
 # Install swoole, pcov and mssql driver
 RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
     apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
     apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
-    pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv pcov && \
+    pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \
     docker-php-ext-enable swoole pdo_sqlsrv pcov && \
     apk del .phpize-deps && \
     rm msodbcsql17_17.5.1.1-1_amd64.apk

+ 44 - 0
data/migrations/Version20210202181026.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ShlinkMigrations;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\Migrations\AbstractMigration;
+
+final class Version20210202181026 extends AbstractMigration
+{
+    private const TITLE = 'title';
+
+    public function up(Schema $schema): void
+    {
+        $shortUrls = $schema->getTable('short_urls');
+        $this->skipIf($shortUrls->hasColumn(self::TITLE));
+
+        $shortUrls->addColumn(self::TITLE, Types::STRING, [
+            'notnull' => false,
+            'length' => 512,
+        ]);
+        $shortUrls->addColumn('title_was_auto_resolved', Types::BOOLEAN, [
+            'default' => false,
+        ]);
+    }
+
+    public function down(Schema $schema): void
+    {
+        $shortUrls = $schema->getTable('short_urls');
+        $this->skipIf(! $shortUrls->hasColumn(self::TITLE));
+        $shortUrls->dropColumn(self::TITLE);
+        $shortUrls->dropColumn('title_was_auto_resolved');
+    }
+
+    /**
+     * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
+     */
+    public function isTransactional(): bool
+    {
+        return false;
+    }
+}

+ 53 - 0
data/migrations/Version20210207100807.php

@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ShlinkMigrations;
+
+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;
+
+final class Version20210207100807 extends AbstractMigration
+{
+    public function up(Schema $schema): void
+    {
+        $visits = $schema->getTable('visits');
+        $shortUrlId = $visits->getColumn('short_url_id');
+
+        $this->skipIf(! $shortUrlId->getNotnull());
+
+        $shortUrlId->setNotnull(false);
+
+        $visits->addColumn('visited_url', Types::STRING, [
+            'length' => Visitor::VISITED_URL_MAX_LENGTH,
+            'notnull' => false,
+        ]);
+        $visits->addColumn('type', Types::STRING, [
+            'length' => 255,
+            'default' => Visit::TYPE_VALID_SHORT_URL,
+        ]);
+    }
+
+    public function down(Schema $schema): void
+    {
+        $visits = $schema->getTable('visits');
+        $shortUrlId = $visits->getColumn('short_url_id');
+
+        $this->skipIf($shortUrlId->getNotnull());
+
+        $shortUrlId->setNotnull(true);
+        $visits->dropColumn('visited_url');
+        $visits->dropColumn('type');
+    }
+
+    /**
+     * @fixme Workaround for https://github.com/doctrine/migrations/issues/1104
+     */
+    public function isTransactional(): bool
+    {
+        return false;
+    }
+}

+ 3 - 3
docker-compose.yml

@@ -3,7 +3,7 @@ version: '3'
 services:
     shlink_nginx:
         container_name: shlink_nginx
-        image: nginx:1.17.10-alpine
+        image: nginx:1.19.6-alpine
         ports:
             - "8000:80"
         volumes:
@@ -34,7 +34,7 @@ services:
 
     shlink_swoole_proxy:
         container_name: shlink_swoole_proxy
-        image: nginx:1.17.10-alpine
+        image: nginx:1.19.6-alpine
         ports:
             - "8002:80"
         volumes:
@@ -120,7 +120,7 @@ services:
 
     shlink_mercure_proxy:
         container_name: shlink_mercure_proxy
-        image: nginx:1.17.10-alpine
+        image: nginx:1.19.6-alpine
         ports:
             - "8001:80"
         volumes:

+ 2 - 0
docker/config/shlink_in_docker.local.php

@@ -125,6 +125,8 @@ return [
         'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
         'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
         'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
+        'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
+        'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
     ],
 
     'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),

+ 35 - 0
docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md

@@ -0,0 +1,35 @@
+# Track visits to 'base_url', 'invalid_short_url' and 'regular_404'
+
+* Status: Accepted
+* Date: 2021-02-07
+
+## Context and problem statement
+
+Shlink has the mechanism to return either custom errors or custom redirects when visiting the instance's base URL, an invalid short URL, or any other kind of URL that would result in a "Not found" error.
+
+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
+
+* 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.
+
+## Decision outcome
+
+The decision is to use the existing table, as making the short URL nullable can be handled seamlessly by using named constructors, and it has a lot of benefits on regards of reusing existing components.
+
+Also, the domain name this kind of visits will receive is "Orphan Visits", as they are detached from any existing short URL.
+
+## Pros and Cons of the Options
+
+### New table
+
+* Good because we don't touch existing models and tables, reducing the risk to introduce a backwards compatibility break.
+* Bad because we will have to repeat data modeling and logic, or refactor some components to support both contexts. This in turn increases the options to introduce a BC break.
+
+### Reuse existing table
+
+* Good because all the mechanisms in place to handle visits will work out of the box, including locating visits and such.
+* Bad because we will have more optional properties, which means more double checks in many places.

+ 1 - 0
docs/adr/README.md

@@ -2,4 +2,5 @@
 
 Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
 
+* [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)
 * [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)

+ 57 - 0
docs/async-api/async-api.json

@@ -58,6 +58,23 @@
                     }
                 }
             }
+        },
+        "http://shlink.io/new-orphan-visit": {
+            "subscribe": {
+                "summary": "Receive information about any new orphan visit.",
+                "operationId": "newOrphanVisit",
+                "message": {
+                    "payload": {
+                        "type": "object",
+                        "additionalProperties": false,
+                        "properties": {
+                            "visit": {
+                                "$ref": "#/components/schemas/OrphanVisit"
+                            }
+                        }
+                    }
+                }
+            }
         }
     },
     "components": {
@@ -179,6 +196,46 @@
                     }
                 }
             },
+            "OrphanVisit": {
+                "allOf": [
+                    {"$ref": "#/components/schemas/Visit"},
+                    {
+                        "type": "object",
+                        "properties": {
+                            "visitedUrl": {
+                                "type": "string",
+                                "nullable": true,
+                                "description": "The originally visited URL that triggered the tracking of this visit"
+                            },
+                            "type": {
+                                "type": "string",
+                                "enum": [
+                                    "invalid_short_url",
+                                    "base_url",
+                                    "regular_404"
+                                ],
+                                "description": "Tells the type of orphan visit"
+                            }
+                        }
+                    }
+                ],
+                "example": {
+                    "referer": "https://t.co",
+                    "date": "2015-08-20T05:05:03+04:00",
+                    "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
+                    "visitLocation": {
+                        "cityName": "Cupertino",
+                        "countryCode": "US",
+                        "countryName": "United States",
+                        "latitude": 37.3042,
+                        "longitude": -122.0946,
+                        "regionName": "California",
+                        "timezone": "America/Los_Angeles"
+                    },
+                    "visitedUrl": "https://doma.in",
+                    "type": "base_url"
+                }
+            },
             "VisitLocation": {
                 "type": "object",
                 "properties": {

+ 23 - 0
docs/swagger/definitions/OrphanVisit.json

@@ -0,0 +1,23 @@
+{
+    "type": "object",
+    "required": ["visitedUrl", "type"],
+    "allOf": [{
+        "$ref": "./Visit.json"
+    }],
+    "properties": {
+        "visitedUrl": {
+            "type": "string",
+            "nullable": true,
+            "description": "The originally visited URL that triggered the tracking of this visit"
+        },
+        "type": {
+            "type": "string",
+            "enum": [
+                "invalid_short_url",
+                "base_url",
+                "regular_404"
+            ],
+            "description": "Tells the type of orphan visit"
+        }
+    }
+}

+ 6 - 0
docs/swagger/definitions/ShortUrl.json

@@ -34,7 +34,13 @@
         },
         "domain": {
             "type": "string",
+            "nullable": true,
             "description": "The domain in which the short URL was created. Null if it belongs to default domain."
+        },
+        "title": {
+            "type": "string",
+            "nullable": true,
+            "description": "A descriptive title of the short URL."
         }
     }
 }

+ 1 - 0
docs/swagger/definitions/Visit.json

@@ -1,5 +1,6 @@
 {
     "type": "object",
+    "required": ["referer", "date", "userAgent", "visitLocation"],
     "properties": {
         "referer": {
             "type": "string",

+ 6 - 2
docs/swagger/definitions/VisitStats.json

@@ -1,10 +1,14 @@
 {
     "type": "object",
-    "required": ["visitsCount"],
+    "required": ["visitsCount", "orphanVisitsCount"],
     "properties": {
         "visitsCount": {
             "type": "number",
-            "description": "The total amount of visits received."
+            "description": "The total amount of visits received on any short URL."
+        },
+        "orphanVisitsCount": {
+            "type": "number",
+            "description": "The total amount of visits that could not be matched to a short URL (visits to the base URL, an invalid short URL or any other kind of 404)."
         }
     }
 }

+ 13 - 4
docs/swagger/paths/v1_short-urls.json

@@ -64,7 +64,9 @@
                         "dateCreated-ASC",
                         "dateCreated-DESC",
                         "visits-ASC",
-                        "visits-DESC"
+                        "visits-DESC",
+                        "title-ASC",
+                        "title-DESC"
                     ]
                 }
             },
@@ -137,7 +139,8 @@
                                         "validUntil": null,
                                         "maxVisits": 100
                                     },
-                                    "domain": null
+                                    "domain": null,
+                                    "title": "Welcome to Steam"
                                 },
                                 {
                                     "shortCode": "12Kb3",
@@ -153,7 +156,8 @@
                                         "validUntil": null,
                                         "maxVisits": null
                                     },
-                                    "domain":  null
+                                    "domain":  null,
+                                    "title": null
                                 },
                                 {
                                     "shortCode": "123bA",
@@ -167,7 +171,8 @@
                                         "validUntil": null,
                                         "maxVisits": null
                                     },
-                                    "domain":  "example.com"
+                                    "domain":  "example.com",
+                                    "title": null
                                 }
                             ],
                             "pagination": {
@@ -264,6 +269,10 @@
                             "validateUrl": {
                                 "description": "Tells if the long URL should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
                                 "type": "boolean"
+                            },
+                            "title": {
+                                "type": "string",
+                                "description": "A descriptive title of the short URL."
                             }
                         }
                     }

+ 2 - 1
docs/swagger/paths/v1_short-urls_shorten.json

@@ -73,7 +73,8 @@
                             "validUntil": null,
                             "maxVisits": 100
                         },
-                        "domain": null
+                        "domain": null,
+                        "title": null
                     },
                     "text/plain": "https://doma.in/abc123"
                 }

+ 48 - 6
docs/swagger/paths/v1_short-urls_{shortCode}.json

@@ -53,7 +53,8 @@
                             "validUntil": null,
                             "maxVisits": 100
                         },
-                        "domain": null
+                        "domain": null,
+                        "title": null
                     }
                 }
             },
@@ -118,19 +119,34 @@
                             },
                             "validSince": {
                                 "description": "The date (in ISO-8601 format) from which this short code will be valid",
-                                "type": "string"
+                                "type": "string",
+                                "nullable": true
                             },
                             "validUntil": {
                                 "description": "The date (in ISO-8601 format) until which this short code will be valid",
-                                "type": "string"
+                                "type": "string",
+                                "nullable": true
                             },
                             "maxVisits": {
                                 "description": "The maximum number of allowed visits for this short code",
-                                "type": "number"
+                                "type": "number",
+                                "nullable": true
                             },
                             "validateUrl": {
                                 "description": "Tells if the long URL (if provided) should or should not be validated as a reachable URL. If not provided, it will fall back to app-level config",
                                 "type": "boolean"
+                            },
+                            "tags": {
+                                "type": "array",
+                                "items": {
+                                    "type": "string"
+                                },
+                                "description": "The list of tags to set to the short URL."
+                            },
+                            "title": {
+                                "type": "string",
+                                "description": "A descriptive title of the short URL.",
+                                "nullable": true
                             }
                         }
                     }
@@ -143,8 +159,34 @@
             }
         ],
         "responses": {
-            "204": {
-                "description": "The short code has been properly updated."
+            "200": {
+                "description": "The short URL has been properly updated.",
+                "content": {
+                    "application/json": {
+                        "schema": {
+                            "$ref": "../definitions/ShortUrl.json"
+                        }
+                    }
+                },
+                "examples": {
+                    "application/json": {
+                        "shortCode": "12Kb3",
+                        "shortUrl": "https://doma.in/12Kb3",
+                        "longUrl": "https://shlink.io",
+                        "dateCreated": "2016-05-01T20:34:16+02:00",
+                        "visitsCount": 1029,
+                        "tags": [
+                            "shlink"
+                        ],
+                        "meta": {
+                            "validSince": "2017-01-21T00:00:00+02:00",
+                            "validUntil": null,
+                            "maxVisits": 100
+                        },
+                        "domain": null,
+                        "title": "Shlink - The URL shortener"
+                    }
+                }
             },
             "400": {
                 "description": "Provided meta arguments are invalid.",

+ 2 - 1
docs/swagger/paths/v1_short-urls_{shortCode}_tags.json

@@ -1,11 +1,12 @@
 {
     "put": {
+        "deprecated": true,
         "operationId": "editShortUrlTags",
         "tags": [
             "Short URLs"
         ],
         "summary": "Edit tags on short URL",
-        "description": "Edit the tags on URL identified by provided short code.",
+        "description": "Edit the tags on URL identified by provided short code.<br />This endpoint is deprecated. Use the [Edit short URL](#/Short%20URLs/editShortUrl) endpoint to edit tags.",
         "parameters": [
             {
                 "$ref": "../parameters/version.json"

+ 2 - 1
docs/swagger/paths/v2_visits.json

@@ -34,7 +34,8 @@
                 "examples": {
                     "application/json": {
                         "visits": {
-                            "visitsCount": 1569874
+                            "visitsCount": 1569874,
+                            "orphanVisitsCount": 71345
                         }
                     }
                 }

+ 141 - 0
docs/swagger/paths/v2_visits_orphan.json

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

+ 11 - 0
docs/swagger/paths/{shortCode}_qr-code.json

@@ -40,6 +40,17 @@
                         "svg"
                     ]
                 }
+            },
+            {
+                "name": "margin",
+                "in": "query",
+                "description": "The margin around the QR code image.",
+                "required": false,
+                "schema": {
+                    "type": "integer",
+                    "minimum": 0,
+                    "default": 0
+                }
             }
         ],
         "responses": {

+ 3 - 0
docs/swagger/swagger.json

@@ -95,6 +95,9 @@
         "/rest/v{version}/tags/{tag}/visits": {
             "$ref": "paths/v2_tags_{tag}_visits.json"
         },
+        "/rest/v{version}/visits/orphan": {
+            "$ref": "paths/v2_visits_orphan.json"
+        },
 
         "/rest/v{version}/domains": {
             "$ref": "paths/v2_domains.json"

+ 13 - 5
module/CLI/config/dependencies.config.php

@@ -11,6 +11,8 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
 use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
 use Shlinkio\Shlink\Core\Domain\DomainService;
 use Shlinkio\Shlink\Core\Service;
+use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
+use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
 use Shlinkio\Shlink\Core\Tag\TagService;
 use Shlinkio\Shlink\Core\Visit;
 use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
@@ -32,6 +34,8 @@ return [
             PhpExecutableFinder::class => InvokableFactory::class,
 
             Util\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
+            Util\ProcessRunner::class => ConfigAbstractFactory::class,
+
             ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
 
             Command\ShortUrl\GenerateShortUrlCommand::class => ConfigAbstractFactory::class,
@@ -60,16 +64,20 @@ return [
 
     ConfigAbstractFactory::class => [
         Util\GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, LOCAL_LOCK_FACTORY],
+        Util\ProcessRunner::class => [SymfonyCli\Helper\ProcessHelper::class],
         ApiKey\RoleResolver::class => [DomainService::class],
 
         Command\ShortUrl\GenerateShortUrlCommand::class => [
             Service\UrlShortener::class,
-            'config.url_shortener.domain',
+            ShortUrlStringifier::class,
             'config.url_shortener.default_short_codes_length',
         ],
         Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
-        Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
-        Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
+        Command\ShortUrl\ListShortUrlsCommand::class => [
+            Service\ShortUrlService::class,
+            ShortUrlDataTransformer::class,
+        ],
+        Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class],
         Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
 
         Command\Visit\LocateVisitsCommand::class => [
@@ -92,14 +100,14 @@ return [
 
         Command\Db\CreateDatabaseCommand::class => [
             LockFactory::class,
-            SymfonyCli\Helper\ProcessHelper::class,
+            Util\ProcessRunner::class,
             PhpExecutableFinder::class,
             Connection::class,
             NoDbNameConnectionFactory::SERVICE_NAME,
         ],
         Command\Db\MigrateDatabaseCommand::class => [
             LockFactory::class,
-            SymfonyCli\Helper\ProcessHelper::class,
+            Util\ProcessRunner::class,
             PhpExecutableFinder::class,
         ],
     ],

+ 7 - 7
module/CLI/src/Command/Api/GenerateKeyCommand.php

@@ -6,11 +6,11 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
 
 use Cake\Chronos\Chronos;
 use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
+use Shlinkio\Shlink\CLI\Command\BaseCommand;
 use Shlinkio\Shlink\CLI\Util\ExitCodes;
 use Shlinkio\Shlink\CLI\Util\ShlinkTable;
 use Shlinkio\Shlink\Rest\ApiKey\Role;
 use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
-use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
 use function Shlinkio\Shlink\Core\arrayToString;
 use function sprintf;
 
-class GenerateKeyCommand extends Command
+class GenerateKeyCommand extends BaseCommand
 {
     public const NAME = 'api-key:generate';
 
@@ -42,9 +42,9 @@ class GenerateKeyCommand extends Command
 
             <info>%command.full_name%</info>
 
-        You can optionally set its expiration date with <comment>--expirationDate</comment> or <comment>-e</comment>:
+        You can optionally set its expiration date with <comment>--expiration-date</comment> or <comment>-e</comment>:
 
-            <info>%command.full_name% --expirationDate 2020-01-01</info>
+            <info>%command.full_name% --expiration-date 2020-01-01</info>
 
         You can also set roles to the API key:
 
@@ -56,8 +56,8 @@ class GenerateKeyCommand extends Command
         $this
             ->setName(self::NAME)
             ->setDescription('Generates a new valid API key.')
-            ->addOption(
-                'expirationDate',
+            ->addOptionWithDeprecatedFallback(
+                'expiration-date',
                 'e',
                 InputOption::VALUE_REQUIRED,
                 'The date in which the API key should expire. Use any valid PHP format.',
@@ -79,7 +79,7 @@ class GenerateKeyCommand extends Command
 
     protected function execute(InputInterface $input, OutputInterface $output): ?int
     {
-        $expirationDate = $input->getOption('expirationDate');
+        $expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
         $apiKey = $this->apiKeyService->create(
             isset($expirationDate) ? Chronos::parse($expirationDate) : null,
             ...$this->roleResolver->determineRoles($input),

+ 5 - 5
module/CLI/src/Command/Api/ListKeysCommand.php

@@ -4,12 +4,12 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\CLI\Command\Api;
 
+use Shlinkio\Shlink\CLI\Command\BaseCommand;
 use Shlinkio\Shlink\CLI\Util\ExitCodes;
 use Shlinkio\Shlink\CLI\Util\ShlinkTable;
 use Shlinkio\Shlink\Rest\ApiKey\Role;
 use Shlinkio\Shlink\Rest\Entity\ApiKey;
 use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
-use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -19,7 +19,7 @@ use function Functional\map;
 use function implode;
 use function sprintf;
 
-class ListKeysCommand extends Command
+class ListKeysCommand extends BaseCommand
 {
     private const ERROR_STRING_PATTERN = '<fg=red>%s</>';
     private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
@@ -40,8 +40,8 @@ class ListKeysCommand extends Command
         $this
             ->setName(self::NAME)
             ->setDescription('Lists all the available API keys.')
-            ->addOption(
-                'enabledOnly',
+            ->addOptionWithDeprecatedFallback(
+                'enabled-only',
                 'e',
                 InputOption::VALUE_NONE,
                 'Tells if only enabled API keys should be returned.',
@@ -50,7 +50,7 @@ class ListKeysCommand extends Command
 
     protected function execute(InputInterface $input, OutputInterface $output): ?int
     {
-        $enabledOnly = $input->getOption('enabledOnly');
+        $enabledOnly = $this->getOptionWithDeprecatedFallback($input, 'enabled-only');
 
         $rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) {
             $expiration = $apiKey->getExpirationDate();

+ 51 - 0
module/CLI/src/Command/BaseCommand.php

@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\CLI\Command;
+
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+
+use function method_exists;
+use function Shlinkio\Shlink\Core\kebabCaseToCamelCase;
+use function sprintf;
+use function str_contains;
+
+abstract class BaseCommand extends Command
+{
+    /**
+     * @param mixed|null $default
+     */
+    protected function addOptionWithDeprecatedFallback(
+        string $name,
+        ?string $shortcut = null,
+        ?int $mode = null,
+        string $description = '',
+        $default = null
+    ): self {
+        $this->addOption($name, $shortcut, $mode, $description, $default);
+
+        if (str_contains($name, '-')) {
+            $camelCaseName = kebabCaseToCamelCase($name);
+            $this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Same as "%s".', $name), $default);
+        }
+
+        return $this;
+    }
+
+    /**
+     * @return bool|string|string[]|null
+     */
+    protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name)
+    {
+        $rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
+        $camelCaseName = kebabCaseToCamelCase($name);
+
+        if (str_contains($rawInput, $camelCaseName)) {
+            return $input->getOption($camelCaseName);
+        }
+
+        return $input->getOption($name);
+    }
+}

+ 10 - 7
module/CLI/src/Command/Db/AbstractDatabaseCommand.php

@@ -6,31 +6,34 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
 
 use Shlinkio\Shlink\CLI\Command\Util\AbstractLockedCommand;
 use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
-use Symfony\Component\Console\Helper\ProcessHelper;
+use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Lock\LockFactory;
 use Symfony\Component\Process\PhpExecutableFinder;
 
 abstract class AbstractDatabaseCommand extends AbstractLockedCommand
 {
-    private ProcessHelper $processHelper;
+    private ProcessRunnerInterface $processRunner;
     private string $phpBinary;
 
-    public function __construct(LockFactory $locker, ProcessHelper $processHelper, PhpExecutableFinder $phpFinder)
-    {
+    public function __construct(
+        LockFactory $locker,
+        ProcessRunnerInterface $processRunner,
+        PhpExecutableFinder $phpFinder
+    ) {
         parent::__construct($locker);
-        $this->processHelper = $processHelper;
+        $this->processRunner = $processRunner;
         $this->phpBinary = $phpFinder->find(false) ?: 'php';
     }
 
     protected function runPhpCommand(OutputInterface $output, array $command): void
     {
         $command = [$this->phpBinary, ...$command, '--no-interaction'];
-        $this->processHelper->mustRun($output, $command);
+        $this->processRunner->run($output, $command);
     }
 
     protected function getLockConfig(): LockedCommandConfig
     {
-        return new LockedCommandConfig($this->getName(), true);
+        return LockedCommandConfig::blocking($this->getName());
     }
 }

+ 3 - 3
module/CLI/src/Command/Db/CreateDatabaseCommand.php

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\Db;
 
 use Doctrine\DBAL\Connection;
 use Shlinkio\Shlink\CLI\Util\ExitCodes;
-use Symfony\Component\Console\Helper\ProcessHelper;
+use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
@@ -26,12 +26,12 @@ class CreateDatabaseCommand extends AbstractDatabaseCommand
 
     public function __construct(
         LockFactory $locker,
-        ProcessHelper $processHelper,
+        ProcessRunnerInterface $processRunner,
         PhpExecutableFinder $phpFinder,
         Connection $conn,
         Connection $noDbNameConn
     ) {
-        parent::__construct($locker, $processHelper, $phpFinder);
+        parent::__construct($locker, $processRunner, $phpFinder);
         $this->regularConn = $conn;
         $this->noDbNameConn = $noDbNameConn;
     }

+ 48 - 36
module/CLI/src/Command/ShortUrl/GenerateShortUrlCommand.php

@@ -4,13 +4,14 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
 
+use Shlinkio\Shlink\CLI\Command\BaseCommand;
 use Shlinkio\Shlink\CLI\Util\ExitCodes;
 use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
 use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
 use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
 use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
-use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
-use Symfony\Component\Console\Command\Command;
+use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
+use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
@@ -23,21 +24,24 @@ use function Functional\flatten;
 use function Functional\unique;
 use function method_exists;
 use function sprintf;
-use function strpos;
+use function str_contains;
 
-class GenerateShortUrlCommand extends Command
+class GenerateShortUrlCommand extends BaseCommand
 {
     public const NAME = 'short-url:generate';
 
     private UrlShortenerInterface $urlShortener;
-    private array $domainConfig;
+    private ShortUrlStringifierInterface $stringifier;
     private int $defaultShortCodeLength;
 
-    public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig, int $defaultShortCodeLength)
-    {
+    public function __construct(
+        UrlShortenerInterface $urlShortener,
+        ShortUrlStringifierInterface $stringifier,
+        int $defaultShortCodeLength
+    ) {
         parent::__construct();
         $this->urlShortener = $urlShortener;
-        $this->domainConfig = $domainConfig;
+        $this->stringifier = $stringifier;
         $this->defaultShortCodeLength = $defaultShortCodeLength;
     }
 
@@ -53,34 +57,34 @@ class GenerateShortUrlCommand extends Command
                 InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
                 'Tags to apply to the new short URL',
             )
-            ->addOption(
-                'validSince',
+            ->addOptionWithDeprecatedFallback(
+                'valid-since',
                 's',
                 InputOption::VALUE_REQUIRED,
                 'The date from which this short URL will be valid. '
                 . 'If someone tries to access it before this date, it will not be found.',
             )
-            ->addOption(
-                'validUntil',
+            ->addOptionWithDeprecatedFallback(
+                'valid-until',
                 'u',
                 InputOption::VALUE_REQUIRED,
                 'The date until which this short URL will be valid. '
                 . 'If someone tries to access it after this date, it will not be found.',
             )
-            ->addOption(
-                'customSlug',
+            ->addOptionWithDeprecatedFallback(
+                'custom-slug',
                 'c',
                 InputOption::VALUE_REQUIRED,
                 'If provided, this slug will be used instead of generating a short code',
             )
-            ->addOption(
-                'maxVisits',
+            ->addOptionWithDeprecatedFallback(
+                'max-visits',
                 'm',
                 InputOption::VALUE_REQUIRED,
                 'This will limit the number of visits for this short URL.',
             )
-            ->addOption(
-                'findIfExists',
+            ->addOptionWithDeprecatedFallback(
+                'find-if-exists',
                 'f',
                 InputOption::VALUE_NONE,
                 'This will force existing matching URL to be returned if found, instead of creating a new one.',
@@ -91,11 +95,11 @@ class GenerateShortUrlCommand extends Command
                 InputOption::VALUE_REQUIRED,
                 'The domain to which this short URL will be attached.',
             )
-            ->addOption(
-                'shortCodeLength',
+            ->addOptionWithDeprecatedFallback(
+                'short-code-length',
                 'l',
                 InputOption::VALUE_REQUIRED,
-                'The length for generated short code (it will be ignored if --customSlug was provided).',
+                'The length for generated short code (it will be ignored if --custom-slug was provided).',
             )
             ->addOption(
                 'validate-url',
@@ -136,26 +140,34 @@ class GenerateShortUrlCommand extends Command
 
         $explodeWithComma = curry('explode')(',');
         $tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
-        $customSlug = $input->getOption('customSlug');
-        $maxVisits = $input->getOption('maxVisits');
-        $shortCodeLength = $input->getOption('shortCodeLength') ?? $this->defaultShortCodeLength;
+        $customSlug = $this->getOptionWithDeprecatedFallback($input, 'custom-slug');
+        $maxVisits = $this->getOptionWithDeprecatedFallback($input, 'max-visits');
+        $shortCodeLength = $this->getOptionWithDeprecatedFallback(
+            $input,
+            'short-code-length',
+        ) ?? $this->defaultShortCodeLength;
         $doValidateUrl = $this->doValidateUrl($input);
 
         try {
-            $shortUrl = $this->urlShortener->shorten($longUrl, $tags, ShortUrlMeta::fromRawData([
-                ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
-                ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
-                ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
-                ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
-                ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
-                ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
-                ShortUrlMetaInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
-                ShortUrlMetaInputFilter::VALIDATE_URL => $doValidateUrl,
+            $shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData([
+                ShortUrlInputFilter::LONG_URL => $longUrl,
+                ShortUrlInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'),
+                ShortUrlInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'),
+                ShortUrlInputFilter::CUSTOM_SLUG => $customSlug,
+                ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
+                ShortUrlInputFilter::FIND_IF_EXISTS => $this->getOptionWithDeprecatedFallback(
+                    $input,
+                    'find-if-exists',
+                ),
+                ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
+                ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
+                ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
+                ShortUrlInputFilter::TAGS => $tags,
             ]));
 
             $io->writeln([
                 sprintf('Processed long URL: <info>%s</info>', $longUrl),
-                sprintf('Generated short URL: <info>%s</info>', $shortUrl->toString($this->domainConfig)),
+                sprintf('Generated short URL: <info>%s</info>', $this->stringifier->stringify($shortUrl)),
             ]);
             return ExitCodes::EXIT_SUCCESS;
         } catch (InvalidUrlException | NonUniqueSlugException $e) {
@@ -168,10 +180,10 @@ class GenerateShortUrlCommand extends Command
     {
         $rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
 
-        if (strpos($rawInput, '--no-validate-url') !== false) {
+        if (str_contains($rawInput, '--no-validate-url')) {
             return false;
         }
-        if (strpos($rawInput, '--validate-url') !== false) {
+        if (str_contains($rawInput, '--validate-url')) {
             return true;
         }
 

+ 18 - 14
module/CLI/src/Command/ShortUrl/GetVisitsCommand.php

@@ -11,8 +11,8 @@ use Shlinkio\Shlink\Common\Util\DateRange;
 use Shlinkio\Shlink\Core\Entity\Visit;
 use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Model\VisitsParams;
-use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
 use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
+use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
@@ -21,16 +21,17 @@ use Symfony\Component\Console\Style\SymfonyStyle;
 
 use function Functional\map;
 use function Functional\select_keys;
+use function sprintf;
 
 class GetVisitsCommand extends AbstractWithDateRangeCommand
 {
     public const NAME = 'short-url:visits';
 
-    private VisitsTrackerInterface $visitsTracker;
+    private VisitsStatsHelperInterface $visitsHelper;
 
-    public function __construct(VisitsTrackerInterface $visitsTracker)
+    public function __construct(VisitsStatsHelperInterface $visitsHelper)
     {
-        $this->visitsTracker = $visitsTracker;
+        $this->visitsHelper = $visitsHelper;
         parent::__construct();
     }
 
@@ -39,18 +40,18 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
         $this
             ->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');
+            ->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
+    protected function getStartDateDesc(string $optionName): string
     {
-        return 'Allows to filter visits, returning only those older than start date';
+        return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName);
     }
 
-    protected function getEndDateDesc(): string
+    protected function getEndDateDesc(string $optionName): string
     {
-        return 'Allows to filter visits, returning only those newer than end date';
+        return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName);
     }
 
     protected function interact(InputInterface $input, OutputInterface $output): void
@@ -70,12 +71,15 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
     protected function execute(InputInterface $input, OutputInterface $output): ?int
     {
         $identifier = ShortUrlIdentifier::fromCli($input);
-        $startDate = $this->getDateOption($input, $output, 'startDate');
-        $endDate = $this->getDateOption($input, $output, 'endDate');
+        $startDate = $this->getStartDateOption($input, $output);
+        $endDate = $this->getEndDateOption($input, $output);
 
-        $paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate)));
+        $paginator = $this->visitsHelper->visitsForShortUrl(
+            $identifier,
+            new VisitsParams(new DateRange($startDate, $endDate)),
+        );
 
-        $rows = map($paginator->getCurrentItems(), function (Visit $visit) {
+        $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']);

+ 46 - 42
module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php

@@ -4,51 +4,53 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
 
-use Laminas\Paginator\Paginator;
 use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
 use Shlinkio\Shlink\CLI\Util\ExitCodes;
 use Shlinkio\Shlink\CLI\Util\ShlinkTable;
-use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
+use Shlinkio\Shlink\Common\Paginator\Paginator;
+use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
+use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
 use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
 use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
 use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
-use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
 use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
 
-use function array_flip;
-use function array_intersect_key;
-use function array_values;
-use function count;
+use function array_pad;
 use function explode;
+use function Functional\map;
 use function implode;
 use function sprintf;
 
 class ListShortUrlsCommand extends AbstractWithDateRangeCommand
 {
-    use PaginatorUtilsTrait;
+    use PagerfantaUtilsTrait;
 
     public const NAME = 'short-url:list';
-    private const COLUMNS_WHITELIST = [
+    private const COLUMNS_TO_SHOW = [
         'shortCode',
+        'title',
         'shortUrl',
         'longUrl',
         'dateCreated',
         'visitsCount',
+    ];
+    private const COLUMNS_TO_SHOW_WITH_TAGS = [
+        ...self::COLUMNS_TO_SHOW,
         'tags',
     ];
 
     private ShortUrlServiceInterface $shortUrlService;
-    private ShortUrlDataTransformer $transformer;
+    private DataTransformerInterface $transformer;
 
-    public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
+    public function __construct(ShortUrlServiceInterface $shortUrlService, DataTransformerInterface $transformer)
     {
         parent::__construct();
         $this->shortUrlService = $shortUrlService;
-        $this->transformer = new ShortUrlDataTransformer($domainConfig);
+        $this->transformer = $transformer;
     }
 
     protected function doConfigure(): void
@@ -60,28 +62,34 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
                 'page',
                 'p',
                 InputOption::VALUE_REQUIRED,
-                'The first page to list (10 items per page unless "--all" is provided)',
+                'The first page to list (10 items per page unless "--all" is provided).',
                 '1',
             )
-            ->addOption(
-                'searchTerm',
+            ->addOptionWithDeprecatedFallback(
+                'search-term',
                 'st',
                 InputOption::VALUE_REQUIRED,
-                'A query used to filter results by searching for it on the longUrl and shortCode fields',
+                'A query used to filter results by searching for it on the longUrl and shortCode fields.',
             )
             ->addOption(
                 'tags',
                 't',
                 InputOption::VALUE_REQUIRED,
-                'A comma-separated list of tags to filter results',
+                'A comma-separated list of tags to filter results.',
             )
-            ->addOption(
-                'orderBy',
+            ->addOptionWithDeprecatedFallback(
+                'order-by',
                 'o',
                 InputOption::VALUE_REQUIRED,
-                'The field from which we want to order by. Pass ASC or DESC separated by a comma',
+                'The field from which you want to order by. '
+                    . 'Define ordering dir by passing ASC or DESC after "," or "-".',
+            )
+            ->addOptionWithDeprecatedFallback(
+                'show-tags',
+                null,
+                InputOption::VALUE_NONE,
+                'Whether to display the tags or not.',
             )
-            ->addOption('showTags', null, InputOption::VALUE_NONE, 'Whether to display the tags or not')
             ->addOption(
                 'all',
                 'a',
@@ -91,14 +99,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
             );
     }
 
-    protected function getStartDateDesc(): string
+    protected function getStartDateDesc(string $optionName): string
     {
-        return 'Allows to filter short URLs, returning only those created after "startDate"';
+        return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName);
     }
 
-    protected function getEndDateDesc(): string
+    protected function getEndDateDesc(string $optionName): string
     {
-        return 'Allows to filter short URLs, returning only those created before "endDate"';
+        return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName);
     }
 
     protected function execute(InputInterface $input, OutputInterface $output): ?int
@@ -106,13 +114,13 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
         $io = new SymfonyStyle($input, $output);
 
         $page = (int) $input->getOption('page');
-        $searchTerm = $input->getOption('searchTerm');
+        $searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term');
         $tags = $input->getOption('tags');
         $tags = ! empty($tags) ? explode(',', $tags) : [];
-        $showTags = (bool) $input->getOption('showTags');
-        $all = (bool) $input->getOption('all');
-        $startDate = $this->getDateOption($input, $output, 'startDate');
-        $endDate = $this->getDateOption($input, $output, 'endDate');
+        $showTags = $this->getOptionWithDeprecatedFallback($input, 'show-tags');
+        $all = $input->getOption('all');
+        $startDate = $this->getStartDateOption($input, $output);
+        $endDate = $this->getEndDateOption($input, $output);
         $orderBy = $this->processOrderBy($input);
 
         $data = [
@@ -132,7 +140,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
             $result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData($data), $all);
             $page++;
 
-            $continue = ! $this->isLastPage($result) && $io->confirm(
+            $continue = $result->hasNextPage() && $io->confirm(
                 sprintf('Continue with page <options=bold>%s</>?', $page),
                 false,
             );
@@ -148,21 +156,20 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
     {
         $result = $this->shortUrlService->listShortUrls($params);
 
-        $headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
+        $headers = ['Short code', 'Title', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
         if ($showTags) {
             $headers[] = 'Tags';
         }
 
         $rows = [];
         foreach ($result as $row) {
+            $columnsToShow = $showTags ? self::COLUMNS_TO_SHOW_WITH_TAGS : self::COLUMNS_TO_SHOW;
             $shortUrl = $this->transformer->transform($row);
             if ($showTags) {
                 $shortUrl['tags'] = implode(', ', $shortUrl['tags']);
-            } else {
-                unset($shortUrl['tags']);
             }
 
-            $rows[] = array_values(array_intersect_key($shortUrl, array_flip(self::COLUMNS_WHITELIST)));
+            $rows[] = map($columnsToShow, fn (string $prop) => $shortUrl[$prop]);
         }
 
         ShlinkTable::fromOutput($output)->render($headers, $rows, $all ? null : $this->formatCurrentPageMessage(
@@ -173,17 +180,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
         return $result;
     }
 
-    /**
-     * @return array|string|null
-     */
-    private function processOrderBy(InputInterface $input)
+    private function processOrderBy(InputInterface $input): ?string
     {
-        $orderBy = $input->getOption('orderBy');
+        $orderBy = $this->getOptionWithDeprecatedFallback($input, 'order-by');
         if (empty($orderBy)) {
             return null;
         }
 
-        $orderBy = explode(',', $orderBy);
-        return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
+        [$field, $dir] = array_pad(explode(',', $orderBy), 2, null);
+        return $dir === null ? $field : sprintf('%s-%s', $field, $dir);
     }
 }

+ 32 - 8
module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php

@@ -5,7 +5,7 @@ declare(strict_types=1);
 namespace Shlinkio\Shlink\CLI\Command\Util;
 
 use Cake\Chronos\Chronos;
-use Symfony\Component\Console\Command\Command;
+use Shlinkio\Shlink\CLI\Command\BaseCommand;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -13,19 +13,42 @@ use Throwable;
 
 use function sprintf;
 
-abstract class AbstractWithDateRangeCommand extends Command
+abstract class AbstractWithDateRangeCommand extends BaseCommand
 {
+    private const START_DATE = 'start-date';
+    private const END_DATE = 'end-date';
+
     final protected function configure(): void
     {
         $this->doConfigure();
         $this
-            ->addOption('startDate', 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc())
-            ->addOption('endDate', 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc());
+            ->addOptionWithDeprecatedFallback(
+                self::START_DATE,
+                's',
+                InputOption::VALUE_REQUIRED,
+                $this->getStartDateDesc(self::START_DATE),
+            )
+            ->addOptionWithDeprecatedFallback(
+                self::END_DATE,
+                'e',
+                InputOption::VALUE_REQUIRED,
+                $this->getEndDateDesc(self::END_DATE),
+            );
+    }
+
+    protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos
+    {
+        return $this->getDateOption($input, $output, self::START_DATE);
     }
 
-    protected function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
+    protected function getEndDateOption(InputInterface $input, OutputInterface $output): ?Chronos
     {
-        $value = $input->getOption($key);
+        return $this->getDateOption($input, $output, self::END_DATE);
+    }
+
+    private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
+    {
+        $value = $this->getOptionWithDeprecatedFallback($input, $key);
         if (empty($value)) {
             return null;
         }
@@ -49,6 +72,7 @@ abstract class AbstractWithDateRangeCommand extends Command
 
     abstract protected function doConfigure(): void;
 
-    abstract protected function getStartDateDesc(): string;
-    abstract protected function getEndDateDesc(): string;
+    abstract protected function getStartDateDesc(string $optionName): string;
+
+    abstract protected function getEndDateDesc(string $optionName): string;
 }

+ 12 - 2
module/CLI/src/Command/Util/LockedCommandConfig.php

@@ -6,19 +6,29 @@ namespace Shlinkio\Shlink\CLI\Command\Util;
 
 final class LockedCommandConfig
 {
-    private const DEFAULT_TTL = 90.0; // 1.5 minutes
+    public const DEFAULT_TTL = 600.0; // 10 minutes
 
     private string $lockName;
     private bool $isBlocking;
     private float $ttl;
 
-    public function __construct(string $lockName, bool $isBlocking = false, float $ttl = self::DEFAULT_TTL)
+    private function __construct(string $lockName, bool $isBlocking, float $ttl = self::DEFAULT_TTL)
     {
         $this->lockName = $lockName;
         $this->isBlocking = $isBlocking;
         $this->ttl = $ttl;
     }
 
+    public static function blocking(string $lockName): self
+    {
+        return new self($lockName, true);
+    }
+
+    public static function nonBlocking(string $lockName): self
+    {
+        return new self($lockName, false);
+    }
+
     public function lockName(): string
     {
         return $this->lockName;

+ 1 - 1
module/CLI/src/Command/Visit/LocateVisitsCommand.php

@@ -208,6 +208,6 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat
 
     protected function getLockConfig(): LockedCommandConfig
     {
-        return new LockedCommandConfig($this->getName());
+        return LockedCommandConfig::nonBlocking($this->getName());
     }
 }

+ 31 - 3
module/CLI/src/Exception/GeolocationDbUpdateFailedException.php

@@ -7,18 +7,46 @@ namespace Shlinkio\Shlink\CLI\Exception;
 use RuntimeException;
 use Throwable;
 
+use function sprintf;
+
 class GeolocationDbUpdateFailedException extends RuntimeException implements ExceptionInterface
 {
     private bool $olderDbExists;
 
-    public static function create(bool $olderDbExists, ?Throwable $prev = null): self
+    public static function withOlderDb(?Throwable $prev = null): self
     {
         $e = new self(
-            'An error occurred while updating geolocation database, and an older version could not be found',
+            'An error occurred while updating geolocation database, but an older DB is already present.',
             0,
             $prev,
         );
-        $e->olderDbExists = $olderDbExists;
+        $e->olderDbExists = true;
+
+        return $e;
+    }
+
+    public static function withoutOlderDb(?Throwable $prev = null): self
+    {
+        $e = new self(
+            'An error occurred while updating geolocation database, and an older version could not be found.',
+            0,
+            $prev,
+        );
+        $e->olderDbExists = false;
+
+        return $e;
+    }
+
+    /**
+     * @param mixed $buildEpoch
+     */
+    public static function withInvalidEpochInOldDb($buildEpoch): self
+    {
+        $e = new self(sprintf(
+            'Build epoch with value "%s" from existing geolocation database, could not be parsed to integer.',
+            $buildEpoch,
+        ));
+        $e->olderDbExists = true;
 
         return $e;
     }

+ 29 - 3
module/CLI/src/Util/GeolocationDbUpdater.php

@@ -6,11 +6,14 @@ namespace Shlinkio\Shlink\CLI\Util;
 
 use Cake\Chronos\Chronos;
 use GeoIp2\Database\Reader;
+use MaxMind\Db\Reader\Metadata;
 use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
 use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
 use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
 use Symfony\Component\Lock\LockFactory;
 
+use function is_int;
+
 class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
 {
     private const LOCK_NAME = 'geolocation-db-update';
@@ -52,7 +55,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
         }
 
         $meta = $this->geoLiteDbReader->metadata();
-        if ($this->buildIsTooOld($meta->buildEpoch)) {
+        if ($this->buildIsTooOld($meta)) {
             $this->downloadNewDb(true, $mustBeUpdated, $handleProgress);
         }
     }
@@ -69,14 +72,37 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
         try {
             $this->dbUpdater->downloadFreshCopy($handleProgress);
         } catch (RuntimeException $e) {
-            throw GeolocationDbUpdateFailedException::create($olderDbExists, $e);
+            throw $olderDbExists
+                ? GeolocationDbUpdateFailedException::withOlderDb($e)
+                : GeolocationDbUpdateFailedException::withoutOlderDb($e);
         }
     }
 
-    private function buildIsTooOld(int $buildTimestamp): bool
+    private function buildIsTooOld(Metadata $meta): bool
     {
+        $buildTimestamp = $this->resolveBuildTimestamp($meta);
         $buildDate = Chronos::createFromTimestamp($buildTimestamp);
         $now = Chronos::now();
+
         return $now->gt($buildDate->addDays(35));
     }
+
+    private function resolveBuildTimestamp(Metadata $meta): int
+    {
+        // In theory the buildEpoch should be an int, but it has been reported to come as a string.
+        // See https://github.com/shlinkio/shlink/issues/1002 for context
+
+        /** @var int|string $buildEpoch */
+        $buildEpoch = $meta->buildEpoch;
+        if (is_int($buildEpoch)) {
+            return $buildEpoch;
+        }
+
+        $intBuildEpoch = (int) $buildEpoch;
+        if ($buildEpoch === (string) $intBuildEpoch) {
+            return $intBuildEpoch;
+        }
+
+        throw GeolocationDbUpdateFailedException::withInvalidEpochInOldDb($buildEpoch);
+    }
 }

+ 60 - 0
module/CLI/src/Util/ProcessRunner.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\CLI\Util;
+
+use Closure;
+use Shlinkio\Shlink\CLI\Command\Util\LockedCommandConfig;
+use Symfony\Component\Console\Helper\DebugFormatterHelper;
+use Symfony\Component\Console\Helper\ProcessHelper;
+use Symfony\Component\Console\Output\ConsoleOutputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Process\Process;
+
+use function spl_object_hash;
+use function sprintf;
+use function str_replace;
+
+class ProcessRunner implements ProcessRunnerInterface
+{
+    private ProcessHelper $helper;
+    private Closure $createProcess;
+
+    public function __construct(ProcessHelper $helper, ?callable $createProcess = null)
+    {
+        $this->helper = $helper;
+        $this->createProcess = $createProcess !== null
+            ? Closure::fromCallable($createProcess)
+            : static fn (array $cmd) => new Process($cmd, null, null, null, LockedCommandConfig::DEFAULT_TTL);
+    }
+
+    public function run(OutputInterface $output, array $cmd): void
+    {
+        if ($output instanceof ConsoleOutputInterface) {
+            $output = $output->getErrorOutput();
+        }
+
+        /** @var DebugFormatterHelper $formatter */
+        $formatter = $this->helper->getHelperSet()->get('debug_formatter');
+        /** @var Process $process */
+        $process = ($this->createProcess)($cmd);
+
+        if ($output->isVeryVerbose()) {
+            $output->write(
+                $formatter->start(spl_object_hash($process), str_replace('<', '\\<', $process->getCommandLine())),
+            );
+        }
+
+        $callback = $output->isDebug() ? $this->helper->wrapCallback($output, $process) : null;
+        $process->mustRun($callback);
+
+        if ($output->isVeryVerbose()) {
+            $message = $process->isSuccessful() ? 'Command ran successfully' : sprintf(
+                '%s Command did not run successfully',
+                $process->getExitCode(),
+            );
+            $output->write($formatter->stop(spl_object_hash($process), $message, $process->isSuccessful()));
+        }
+    }
+}

+ 12 - 0
module/CLI/src/Util/ProcessRunnerInterface.php

@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\CLI\Util;
+
+use Symfony\Component\Console\Output\OutputInterface;
+
+interface ProcessRunnerInterface
+{
+    public function run(OutputInterface $output, array $cmd): void;
+}

+ 1 - 1
module/CLI/test/Command/Api/GenerateKeyCommandTest.php

@@ -55,7 +55,7 @@ class GenerateKeyCommandTest extends TestCase
         $this->apiKeyService->create(Argument::type(Chronos::class))->shouldBeCalledOnce()
                                                                     ->willReturn(new ApiKey());
         $this->commandTester->execute([
-            '--expirationDate' => '2016-01-01',
+            '--expiration-date' => '2016-01-01',
         ]);
     }
 }

+ 1 - 1
module/CLI/test/Command/Api/ListKeysCommandTest.php

@@ -39,7 +39,7 @@ class ListKeysCommandTest extends TestCase
     {
         $listKeys = $this->apiKeyService->listKeys($enabledOnly)->willReturn($keys);
 
-        $this->commandTester->execute(['--enabledOnly' => $enabledOnly]);
+        $this->commandTester->execute(['--enabled-only' => $enabledOnly]);
         $output = $this->commandTester->getDisplay();
 
         self::assertEquals($expected, $output);

+ 4 - 5
module/CLI/test/Command/Db/CreateDatabaseCommandTest.php

@@ -12,14 +12,13 @@ use Prophecy\Argument;
 use Prophecy\PhpUnit\ProphecyTrait;
 use Prophecy\Prophecy\ObjectProphecy;
 use Shlinkio\Shlink\CLI\Command\Db\CreateDatabaseCommand;
+use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
 use Symfony\Component\Console\Application;
-use Symfony\Component\Console\Helper\ProcessHelper;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Tester\CommandTester;
 use Symfony\Component\Lock\LockFactory;
 use Symfony\Component\Lock\LockInterface;
 use Symfony\Component\Process\PhpExecutableFinder;
-use Symfony\Component\Process\Process;
 
 class CreateDatabaseCommandTest extends TestCase
 {
@@ -43,7 +42,7 @@ class CreateDatabaseCommandTest extends TestCase
         $phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
         $phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
 
-        $this->processHelper = $this->prophesize(ProcessHelper::class);
+        $this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
         $this->schemaManager = $this->prophesize(AbstractSchemaManager::class);
         $this->databasePlatform = $this->prophesize(AbstractPlatform::class);
 
@@ -113,12 +112,12 @@ class CreateDatabaseCommandTest extends TestCase
         $createDatabase = $this->schemaManager->createDatabase($shlinkDatabase)->will(function (): void {
         });
         $listTables = $this->schemaManager->listTableNames()->willReturn([]);
-        $runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
+        $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
             '/usr/local/bin/php',
             CreateDatabaseCommand::DOCTRINE_SCRIPT,
             CreateDatabaseCommand::DOCTRINE_CREATE_SCHEMA_COMMAND,
             '--no-interaction',
-        ], Argument::cetera())->willReturn(new Process([]));
+        ]);
 
         $this->commandTester->execute([]);
         $output = $this->commandTester->getDisplay();

+ 4 - 5
module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php

@@ -9,14 +9,13 @@ use Prophecy\Argument;
 use Prophecy\PhpUnit\ProphecyTrait;
 use Prophecy\Prophecy\ObjectProphecy;
 use Shlinkio\Shlink\CLI\Command\Db\MigrateDatabaseCommand;
+use Shlinkio\Shlink\CLI\Util\ProcessRunnerInterface;
 use Symfony\Component\Console\Application;
-use Symfony\Component\Console\Helper\ProcessHelper;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Tester\CommandTester;
 use Symfony\Component\Lock\LockFactory;
 use Symfony\Component\Lock\LockInterface;
 use Symfony\Component\Process\PhpExecutableFinder;
-use Symfony\Component\Process\Process;
 
 class MigrateDatabaseCommandTest extends TestCase
 {
@@ -37,7 +36,7 @@ class MigrateDatabaseCommandTest extends TestCase
         $phpExecutableFinder = $this->prophesize(PhpExecutableFinder::class);
         $phpExecutableFinder->find(false)->willReturn('/usr/local/bin/php');
 
-        $this->processHelper = $this->prophesize(ProcessHelper::class);
+        $this->processHelper = $this->prophesize(ProcessRunnerInterface::class);
 
         $command = new MigrateDatabaseCommand(
             $locker->reveal(),
@@ -53,12 +52,12 @@ class MigrateDatabaseCommandTest extends TestCase
     /** @test */
     public function migrationsCommandIsRunWithProperVerbosity(): void
     {
-        $runCommand = $this->processHelper->mustRun(Argument::type(OutputInterface::class), [
+        $runCommand = $this->processHelper->run(Argument::type(OutputInterface::class), [
             '/usr/local/bin/php',
             MigrateDatabaseCommand::DOCTRINE_MIGRATIONS_SCRIPT,
             MigrateDatabaseCommand::DOCTRINE_MIGRATE_COMMAND,
             '--no-interaction',
-        ], Argument::cetera())->willReturn(new Process([]));
+        ]);
 
         $this->commandTester->execute([]);
         $output = $this->commandTester->getDisplay();

+ 20 - 19
module/CLI/test/Command/ShortUrl/GenerateShortUrlCommandTest.php

@@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
 use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
 use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
 use Shlinkio\Shlink\Core\Service\UrlShortener;
+use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
 use Symfony\Component\Console\Application;
 use Symfony\Component\Console\Tester\CommandTester;
 
@@ -23,18 +24,17 @@ class GenerateShortUrlCommandTest extends TestCase
 {
     use ProphecyTrait;
 
-    private const DOMAIN_CONFIG = [
-        'schema' => 'http',
-        'hostname' => 'foo.com',
-    ];
-
     private CommandTester $commandTester;
     private ObjectProphecy $urlShortener;
+    private ObjectProphecy $stringifier;
 
     public function setUp(): void
     {
         $this->urlShortener = $this->prophesize(UrlShortener::class);
-        $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), self::DOMAIN_CONFIG, 5);
+        $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class);
+        $this->stringifier->stringify(Argument::type(ShortUrl::class))->willReturn('');
+
+        $command = new GenerateShortUrlCommand($this->urlShortener->reveal(), $this->stringifier->reveal(), 5);
         $app = new Application();
         $app->add($command);
         $this->commandTester = new CommandTester($command);
@@ -43,18 +43,20 @@ class GenerateShortUrlCommandTest extends TestCase
     /** @test */
     public function properShortCodeIsCreatedIfLongUrlIsCorrect(): void
     {
-        $shortUrl = new ShortUrl('');
+        $shortUrl = ShortUrl::createEmpty();
         $urlToShortCode = $this->urlShortener->shorten(Argument::cetera())->willReturn($shortUrl);
+        $stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
 
         $this->commandTester->execute([
             'longUrl' => 'http://domain.com/foo/bar',
-            '--maxVisits' => '3',
+            '--max-visits' => '3',
         ]);
         $output = $this->commandTester->getDisplay();
 
         self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
-        self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
+        self::assertStringContainsString('stringified_short_url', $output);
         $urlToShortCode->shouldHaveBeenCalledOnce();
+        $stringify->shouldHaveBeenCalledOnce();
     }
 
     /** @test */
@@ -78,7 +80,7 @@ class GenerateShortUrlCommandTest extends TestCase
             NonUniqueSlugException::fromSlug('my-slug'),
         );
 
-        $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);
+        $this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
         $output = $this->commandTester->getDisplay();
 
         self::assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
@@ -89,15 +91,15 @@ class GenerateShortUrlCommandTest extends TestCase
     /** @test */
     public function properlyProcessesProvidedTags(): void
     {
-        $shortUrl = new ShortUrl('');
+        $shortUrl = ShortUrl::createEmpty();
         $urlToShortCode = $this->urlShortener->shorten(
-            Argument::type('string'),
-            Argument::that(function (array $tags) {
+            Argument::that(function (ShortUrlMeta $meta) {
+                $tags = $meta->getTags();
                 Assert::assertEquals(['foo', 'bar', 'baz', 'boo', 'zar'], $tags);
-                return $tags;
+                return true;
             }),
-            Argument::cetera(),
         )->willReturn($shortUrl);
+        $stringify = $this->stringifier->stringify($shortUrl)->willReturn('stringified_short_url');
 
         $this->commandTester->execute([
             'longUrl' => 'http://domain.com/foo/bar',
@@ -106,8 +108,9 @@ class GenerateShortUrlCommandTest extends TestCase
         $output = $this->commandTester->getDisplay();
 
         self::assertEquals(ExitCodes::EXIT_SUCCESS, $this->commandTester->getStatusCode());
-        self::assertStringContainsString($shortUrl->toString(self::DOMAIN_CONFIG), $output);
+        self::assertStringContainsString('stringified_short_url', $output);
         $urlToShortCode->shouldHaveBeenCalledOnce();
+        $stringify->shouldHaveBeenCalledOnce();
     }
 
     /**
@@ -116,10 +119,8 @@ class GenerateShortUrlCommandTest extends TestCase
      */
     public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void
     {
-        $shortUrl = new ShortUrl('');
+        $shortUrl = ShortUrl::createEmpty();
         $urlToShortCode = $this->urlShortener->shorten(
-            Argument::type('string'),
-            Argument::type('array'),
             Argument::that(function (ShortUrlMeta $meta) use ($expectedValidateUrl) {
                 Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl());
                 return $meta;

+ 18 - 16
module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php

@@ -5,13 +5,13 @@ declare(strict_types=1);
 namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
 
 use Cake\Chronos\Chronos;
-use Laminas\Paginator\Adapter\ArrayAdapter;
-use Laminas\Paginator\Paginator;
+use Pagerfanta\Adapter\ArrayAdapter;
 use PHPUnit\Framework\TestCase;
 use Prophecy\Argument;
 use Prophecy\PhpUnit\ProphecyTrait;
 use Prophecy\Prophecy\ObjectProphecy;
 use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand;
+use Shlinkio\Shlink\Common\Paginator\Paginator;
 use Shlinkio\Shlink\Common\Util\DateRange;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
 use Shlinkio\Shlink\Core\Entity\Visit;
@@ -19,7 +19,7 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
 use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Model\Visitor;
 use Shlinkio\Shlink\Core\Model\VisitsParams;
-use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
+use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
 use Shlinkio\Shlink\IpGeolocation\Model\Location;
 use Symfony\Component\Console\Application;
 use Symfony\Component\Console\Tester\CommandTester;
@@ -31,12 +31,12 @@ class GetVisitsCommandTest extends TestCase
     use ProphecyTrait;
 
     private CommandTester $commandTester;
-    private ObjectProphecy $visitsTracker;
+    private ObjectProphecy $visitsHelper;
 
     public function setUp(): void
     {
-        $this->visitsTracker = $this->prophesize(VisitsTrackerInterface::class);
-        $command = new GetVisitsCommand($this->visitsTracker->reveal());
+        $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
+        $command = new GetVisitsCommand($this->visitsHelper->reveal());
         $app = new Application();
         $app->add($command);
         $this->commandTester = new CommandTester($command);
@@ -46,7 +46,7 @@ class GetVisitsCommandTest extends TestCase
     public function noDateFlagsTriesToListWithoutDateRange(): void
     {
         $shortCode = 'abc123';
-        $this->visitsTracker->info(
+        $this->visitsHelper->visitsForShortUrl(
             new ShortUrlIdentifier($shortCode),
             new VisitsParams(new DateRange(null, null)),
         )
@@ -62,7 +62,7 @@ class GetVisitsCommandTest extends TestCase
         $shortCode = 'abc123';
         $startDate = '2016-01-01';
         $endDate = '2016-02-01';
-        $this->visitsTracker->info(
+        $this->visitsHelper->visitsForShortUrl(
             new ShortUrlIdentifier($shortCode),
             new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
         )
@@ -71,8 +71,8 @@ class GetVisitsCommandTest extends TestCase
 
         $this->commandTester->execute([
             'shortCode' => $shortCode,
-            '--startDate' => $startDate,
-            '--endDate' => $endDate,
+            '--start-date' => $startDate,
+            '--end-date' => $endDate,
         ]);
     }
 
@@ -81,18 +81,20 @@ class GetVisitsCommandTest extends TestCase
     {
         $shortCode = 'abc123';
         $startDate = 'foo';
-        $info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange()))
-            ->willReturn(new Paginator(new ArrayAdapter([])));
+        $info = $this->visitsHelper->visitsForShortUrl(
+            new ShortUrlIdentifier($shortCode),
+            new VisitsParams(new DateRange()),
+        )->willReturn(new Paginator(new ArrayAdapter([])));
 
         $this->commandTester->execute([
             'shortCode' => $shortCode,
-            '--startDate' => $startDate,
+            '--start-date' => $startDate,
         ]);
         $output = $this->commandTester->getDisplay();
 
         $info->shouldHaveBeenCalledOnce();
         self::assertStringContainsString(
-            sprintf('Ignored provided "startDate" since its value "%s" is not a valid date', $startDate),
+            sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate),
             $output,
         );
     }
@@ -101,9 +103,9 @@ class GetVisitsCommandTest extends TestCase
     public function outputIsProperlyGenerated(): void
     {
         $shortCode = 'abc123';
-        $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
+        $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
             new Paginator(new ArrayAdapter([
-                (new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
+                Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate(
                     new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
                 ),
             ])),

+ 24 - 19
module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php

@@ -5,16 +5,18 @@ declare(strict_types=1);
 namespace ShlinkioTest\Shlink\CLI\Command\ShortUrl;
 
 use Cake\Chronos\Chronos;
-use Laminas\Paginator\Adapter\ArrayAdapter;
-use Laminas\Paginator\Paginator;
+use Pagerfanta\Adapter\ArrayAdapter;
 use PHPUnit\Framework\TestCase;
 use Prophecy\Argument;
 use Prophecy\PhpUnit\ProphecyTrait;
 use Prophecy\Prophecy\ObjectProphecy;
 use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
+use Shlinkio\Shlink\Common\Paginator\Paginator;
 use Shlinkio\Shlink\Core\Entity\ShortUrl;
 use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
 use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
+use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
+use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
 use Symfony\Component\Console\Application;
 use Symfony\Component\Console\Tester\CommandTester;
 
@@ -31,7 +33,9 @@ class ListShortUrlsCommandTest extends TestCase
     {
         $this->shortUrlService = $this->prophesize(ShortUrlServiceInterface::class);
         $app = new Application();
-        $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), []);
+        $command = new ListShortUrlsCommand($this->shortUrlService->reveal(), new ShortUrlDataTransformer(
+            new ShortUrlStringifier([]),
+        ));
         $app->add($command);
         $this->commandTester = new CommandTester($command);
     }
@@ -42,7 +46,7 @@ class ListShortUrlsCommandTest extends TestCase
         // The paginator will return more than one page
         $data = [];
         for ($i = 0; $i < 50; $i++) {
-            $data[] = new ShortUrl('url_' . $i);
+            $data[] = ShortUrl::withLongUrl('url_' . $i);
         }
 
         $this->shortUrlService->listShortUrls(Argument::cetera())
@@ -56,6 +60,7 @@ class ListShortUrlsCommandTest extends TestCase
         self::assertStringContainsString('Continue with page 2?', $output);
         self::assertStringContainsString('Continue with page 3?', $output);
         self::assertStringContainsString('Continue with page 4?', $output);
+        self::assertStringNotContainsString('Continue with page 5?', $output);
     }
 
     /** @test */
@@ -64,7 +69,7 @@ class ListShortUrlsCommandTest extends TestCase
         // The paginator will return more than one page
         $data = [];
         for ($i = 0; $i < 30; $i++) {
-            $data[] = new ShortUrl('url_' . $i);
+            $data[] = ShortUrl::withLongUrl('url_' . $i);
         }
 
         $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
@@ -89,7 +94,7 @@ class ListShortUrlsCommandTest extends TestCase
     {
         $page = 5;
         $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page]))
-            ->willReturn(new Paginator(new ArrayAdapter()))
+            ->willReturn(new Paginator(new ArrayAdapter([])))
             ->shouldBeCalledOnce();
 
         $this->commandTester->setInputs(['y']);
@@ -100,11 +105,11 @@ class ListShortUrlsCommandTest extends TestCase
     public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
     {
         $this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
-            ->willReturn(new Paginator(new ArrayAdapter()))
+            ->willReturn(new Paginator(new ArrayAdapter([])))
             ->shouldBeCalledOnce();
 
         $this->commandTester->setInputs(['y']);
-        $this->commandTester->execute(['--showTags' => true]);
+        $this->commandTester->execute(['--show-tags' => true]);
         $output = $this->commandTester->getDisplay();
         self::assertStringContainsString('Tags', $output);
     }
@@ -127,7 +132,7 @@ class ListShortUrlsCommandTest extends TestCase
             'tags' => $tags,
             'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
             'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
-        ]))->willReturn(new Paginator(new ArrayAdapter()));
+        ]))->willReturn(new Paginator(new ArrayAdapter([])));
 
         $this->commandTester->setInputs(['n']);
         $this->commandTester->execute($commandArgs);
@@ -139,22 +144,22 @@ class ListShortUrlsCommandTest extends TestCase
     {
         yield [[], 1, null, []];
         yield [['--page' => $page = 3], $page, null, []];
-        yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, []];
+        yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, []];
         yield [
-            ['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
+            ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
             $page,
             $searchTerm,
             explode(',', $tags),
         ];
         yield [
-            ['--startDate' => $startDate = '2019-01-01'],
+            ['--start-date' => $startDate = '2019-01-01'],
             1,
             null,
             [],
             $startDate,
         ];
         yield [
-            ['--endDate' => $endDate = '2020-05-23'],
+            ['--end-date' => $endDate = '2020-05-23'],
             1,
             null,
             [],
@@ -162,7 +167,7 @@ class ListShortUrlsCommandTest extends TestCase
             $endDate,
         ];
         yield [
-            ['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
+            ['--start-date' => $startDate = '2019-01-01', '--end-date' => $endDate = '2020-05-23'],
             1,
             null,
             [],
@@ -180,7 +185,7 @@ class ListShortUrlsCommandTest extends TestCase
     {
         $listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
             'orderBy' => $expectedOrderBy,
-        ]))->willReturn(new Paginator(new ArrayAdapter()));
+        ]))->willReturn(new Paginator(new ArrayAdapter([])));
 
         $this->commandTester->setInputs(['n']);
         $this->commandTester->execute($commandArgs);
@@ -191,9 +196,9 @@ class ListShortUrlsCommandTest extends TestCase
     public function provideOrderBy(): iterable
     {
         yield [[], null];
-        yield [['--orderBy' => 'foo'], 'foo'];
-        yield [['--orderBy' => 'foo,ASC'], ['foo' => 'ASC']];
-        yield [['--orderBy' => 'bar,DESC'], ['bar' => 'DESC']];
+        yield [['--order-by' => 'foo'], 'foo'];
+        yield [['--order-by' => 'foo,ASC'], ['foo' => 'ASC']];
+        yield [['--order-by' => 'bar,DESC'], ['bar' => 'DESC']];
     }
 
     /** @test */
@@ -207,7 +212,7 @@ class ListShortUrlsCommandTest extends TestCase
             'endDate' => null,
             'orderBy' => null,
             'itemsPerPage' => -1,
-        ]))->willReturn(new Paginator(new ArrayAdapter()));
+        ]))->willReturn(new Paginator(new ArrayAdapter([])));
 
         $this->commandTester->execute(['--all' => true]);
 

+ 1 - 1
module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php

@@ -41,7 +41,7 @@ class ResolveUrlCommandTest extends TestCase
     {
         $shortCode = 'abc123';
         $expectedUrl = 'http://domain.com/foo/bar';
-        $shortUrl = new ShortUrl($expectedUrl);
+        $shortUrl = ShortUrl::withLongUrl($expectedUrl);
         $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
                                                                                ->shouldBeCalledOnce();
 

+ 7 - 5
module/CLI/test/Command/Visit/LocateVisitsCommandTest.php

@@ -52,7 +52,7 @@ class LocateVisitsCommandTest extends TestCase
         $this->lock->acquire(false)->willReturn(true);
         $this->lock->release()->will(function (): void {
         });
-        $locker->createLock(Argument::type('string'), 90.0, false)->willReturn($this->lock->reveal());
+        $locker->createLock(Argument::type('string'), 600.0, false)->willReturn($this->lock->reveal());
 
         $command = new LocateVisitsCommand(
             $this->visitService->reveal(),
@@ -77,7 +77,7 @@ class LocateVisitsCommandTest extends TestCase
         bool $expectWarningPrint,
         array $args
     ): void {
-        $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
+        $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
         $location = new VisitLocation(Location::emptyInstance());
         $mockMethodBehavior = $this->invokeHelperMethods($visit, $location);
 
@@ -121,7 +121,7 @@ class LocateVisitsCommandTest extends TestCase
      */
     public function localhostAndEmptyAddressesAreIgnored(?string $address, string $message): void
     {
-        $visit = new Visit(new ShortUrl(''), new Visitor('', '', $address));
+        $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', $address, ''));
         $location = new VisitLocation(Location::emptyInstance());
 
         $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
@@ -154,7 +154,7 @@ class LocateVisitsCommandTest extends TestCase
     /** @test */
     public function errorWhileLocatingIpIsDisplayed(): void
     {
-        $visit = new Visit(new ShortUrl(''), new Visitor('', '', '1.2.3.4'));
+        $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('', '', '1.2.3.4', ''));
         $location = new VisitLocation(Location::emptyInstance());
 
         $locateVisits = $this->visitService->locateUnlocatedVisits(Argument::cetera())->will(
@@ -217,7 +217,9 @@ class LocateVisitsCommandTest extends TestCase
                 $mustBeUpdated($olderDbExists);
                 $handleProgress(100, 50);
 
-                throw GeolocationDbUpdateFailedException::create($olderDbExists);
+                throw $olderDbExists
+                    ? GeolocationDbUpdateFailedException::withOlderDb()
+                    : GeolocationDbUpdateFailedException::withoutOlderDb();
             },
         );
 

+ 38 - 10
module/CLI/test/Exception/GeolocationDbUpdateFailedExceptionTest.php

@@ -14,26 +14,54 @@ class GeolocationDbUpdateFailedExceptionTest extends TestCase
 {
     /**
      * @test
-     * @dataProvider provideCreateArgs
+     * @dataProvider providePrev
      */
-    public function createBuildsException(bool $olderDbExists, ?Throwable $prev): void
+    public function withOlderDbBuildsException(?Throwable $prev): void
     {
-        $e = GeolocationDbUpdateFailedException::create($olderDbExists, $prev);
+        $e = GeolocationDbUpdateFailedException::withOlderDb($prev);
 
-        self::assertEquals($olderDbExists, $e->olderDbExists());
+        self::assertTrue($e->olderDbExists());
         self::assertEquals(
-            'An error occurred while updating geolocation database, and an older version could not be found',
+            'An error occurred while updating geolocation database, but an older DB is already present.',
             $e->getMessage(),
         );
         self::assertEquals(0, $e->getCode());
         self::assertEquals($prev, $e->getPrevious());
     }
 
-    public function provideCreateArgs(): iterable
+    /**
+     * @test
+     * @dataProvider providePrev
+     */
+    public function withoutOlderDbBuildsException(?Throwable $prev): void
     {
-        yield 'older DB and no prev' => [true, null];
-        yield 'older DB and prev' => [true, new RuntimeException('prev')];
-        yield 'no older DB and no prev' => [false, null];
-        yield 'no older DB and prev' => [false, new Exception('prev')];
+        $e = GeolocationDbUpdateFailedException::withoutOlderDb($prev);
+
+        self::assertFalse($e->olderDbExists());
+        self::assertEquals(
+            'An error occurred while updating geolocation database, and an older version could not be found.',
+            $e->getMessage(),
+        );
+        self::assertEquals(0, $e->getCode());
+        self::assertEquals($prev, $e->getPrevious());
+    }
+
+    public function providePrev(): iterable
+    {
+        yield 'no prev' => [null];
+        yield 'RuntimeException' => [new RuntimeException('prev')];
+        yield 'Exception' => [new Exception('prev')];
+    }
+
+    /** @test */
+    public function withInvalidEpochInOldDbBuildsException(): void
+    {
+        $e = GeolocationDbUpdateFailedException::withInvalidEpochInOldDb('foobar');
+
+        self::assertTrue($e->olderDbExists());
+        self::assertEquals(
+            'Build epoch with value "foobar" from existing geolocation database, could not be parsed to integer.',
+            $e->getMessage(),
+        );
     }
 }

+ 49 - 24
module/CLI/test/Util/GeolocationDbUpdaterTest.php

@@ -80,17 +80,9 @@ class GeolocationDbUpdaterTest extends TestCase
     public function exceptionIsThrownWhenOlderDbIsTooOldAndDownloadFails(int $days): void
     {
         $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
-        $getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
-            'binary_format_major_version' => '',
-            'binary_format_minor_version' => '',
-            'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
-            'database_type' => '',
-            'languages' => '',
-            'description' => '',
-            'ip_version' => '',
-            'node_count' => 1,
-            'record_size' => 4,
-        ]));
+        $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch(
+            Chronos::now()->subDays($days)->getTimestamp(),
+        ));
         $prev = new RuntimeException('');
         $download = $this->dbUpdater->downloadFreshCopy(null)->willThrow($prev);
 
@@ -120,21 +112,12 @@ class GeolocationDbUpdaterTest extends TestCase
     /**
      * @test
      * @dataProvider provideSmallDays
+     * @param string|int $buildEpoch
      */
-    public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek(int $days): void
+    public function databaseIsNotUpdatedIfItIsYoungerThanOneWeek($buildEpoch): void
     {
         $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
-        $getMeta = $this->geoLiteDbReader->metadata()->willReturn(new Metadata([
-            'binary_format_major_version' => '',
-            'binary_format_minor_version' => '',
-            'build_epoch' => Chronos::now()->subDays($days)->getTimestamp(),
-            'database_type' => '',
-            'languages' => '',
-            'description' => '',
-            'ip_version' => '',
-            'node_count' => 1,
-            'record_size' => 4,
-        ]));
+        $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch($buildEpoch));
         $download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
         });
 
@@ -147,6 +130,48 @@ class GeolocationDbUpdaterTest extends TestCase
 
     public function provideSmallDays(): iterable
     {
-        return map(range(0, 34), fn (int $days) => [$days]);
+        $generateParamsWithTimestamp = static function (int $days) {
+            $timestamp = Chronos::now()->subDays($days)->getTimestamp();
+            return [$days % 2 === 0 ? $timestamp : (string) $timestamp];
+        };
+
+        return map(range(0, 34), $generateParamsWithTimestamp);
+    }
+
+    /** @test */
+    public function exceptionIsThrownWhenCheckingExistingDatabaseWithInvalidBuildEpoch(): void
+    {
+        $fileExists = $this->dbUpdater->databaseFileExists()->willReturn(true);
+        $getMeta = $this->geoLiteDbReader->metadata()->willReturn($this->buildMetaWithBuildEpoch('invalid'));
+        $download = $this->dbUpdater->downloadFreshCopy(null)->will(function (): void {
+        });
+
+        $this->expectException(GeolocationDbUpdateFailedException::class);
+        $this->expectExceptionMessage(
+            'Build epoch with value "invalid" from existing geolocation database, could not be parsed to integer.',
+        );
+        $fileExists->shouldBeCalledOnce();
+        $getMeta->shouldBeCalledOnce();
+        $download->shouldNotBeCalled();
+
+        $this->geolocationDbUpdater->checkDbUpdate();
+    }
+
+    /**
+     * @param string|int $buildEpoch
+     */
+    private function buildMetaWithBuildEpoch($buildEpoch): Metadata
+    {
+        return new Metadata([
+            'binary_format_major_version' => '',
+            'binary_format_minor_version' => '',
+            'build_epoch' => $buildEpoch,
+            'database_type' => '',
+            'languages' => '',
+            'description' => '',
+            'ip_version' => '',
+            'node_count' => 1,
+            'record_size' => 4,
+        ]);
     }
 }

+ 106 - 0
module/CLI/test/Util/ProcessRunnerTest.php

@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ShlinkioTest\Shlink\CLI\Util;
+
+use PHPUnit\Framework\TestCase;
+use Prophecy\Argument;
+use Prophecy\PhpUnit\ProphecyTrait;
+use Prophecy\Prophecy\ObjectProphecy;
+use Shlinkio\Shlink\CLI\Util\ProcessRunner;
+use Symfony\Component\Console\Helper\DebugFormatterHelper;
+use Symfony\Component\Console\Helper\HelperSet;
+use Symfony\Component\Console\Helper\ProcessHelper;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Process\Process;
+
+class ProcessRunnerTest extends TestCase
+{
+    use ProphecyTrait;
+
+    private ProcessRunner $runner;
+    private ObjectProphecy $helper;
+    private ObjectProphecy $formatter;
+    private ObjectProphecy $process;
+    private ObjectProphecy $output;
+
+    protected function setUp(): void
+    {
+        $this->helper = $this->prophesize(ProcessHelper::class);
+        $this->formatter = $this->prophesize(DebugFormatterHelper::class);
+        $helperSet = $this->prophesize(HelperSet::class);
+        $helperSet->get('debug_formatter')->willReturn($this->formatter->reveal());
+        $this->helper->getHelperSet()->willReturn($helperSet->reveal());
+        $this->process = $this->prophesize(Process::class);
+
+        $this->runner = new ProcessRunner($this->helper->reveal(), fn () => $this->process->reveal());
+        $this->output = $this->prophesize(OutputInterface::class);
+    }
+
+    /** @test */
+    public function noMessagesAreWrittenWhenOutputIsNotVerbose(): void
+    {
+        $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
+        $isDebug = $this->output->isDebug()->willReturn(false);
+        $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
+
+        $this->runner->run($this->output->reveal(), []);
+
+        $isVeryVerbose->shouldHaveBeenCalledTimes(2);
+        $isDebug->shouldHaveBeenCalledOnce();
+        $mustRun->shouldHaveBeenCalledOnce();
+        $this->process->isSuccessful()->shouldNotHaveBeenCalled();
+        $this->process->getCommandLine()->shouldNotHaveBeenCalled();
+        $this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
+        $this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
+        $this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
+        $this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
+    }
+
+    /** @test */
+    public function someMessagesAreWrittenWhenOutputIsVerbose(): void
+    {
+        $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(true);
+        $isDebug = $this->output->isDebug()->willReturn(false);
+        $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
+        $isSuccessful = $this->process->isSuccessful()->willReturn(true);
+        $getCommandLine = $this->process->getCommandLine()->willReturn('true');
+        $start = $this->formatter->start(Argument::cetera())->willReturn('');
+        $stop = $this->formatter->stop(Argument::cetera())->willReturn('');
+
+        $this->runner->run($this->output->reveal(), []);
+
+        $isVeryVerbose->shouldHaveBeenCalledTimes(2);
+        $isDebug->shouldHaveBeenCalledOnce();
+        $mustRun->shouldHaveBeenCalledOnce();
+        $this->output->write(Argument::cetera())->shouldHaveBeenCalledTimes(2);
+        $this->helper->wrapCallback(Argument::cetera())->shouldNotHaveBeenCalled();
+        $isSuccessful->shouldHaveBeenCalledTimes(2);
+        $getCommandLine->shouldHaveBeenCalledOnce();
+        $start->shouldHaveBeenCalledOnce();
+        $stop->shouldHaveBeenCalledOnce();
+    }
+
+    /** @test */
+    public function wrapsCallbackWhenOutputIsDebug(): void
+    {
+        $isVeryVerbose = $this->output->isVeryVerbose()->willReturn(false);
+        $isDebug = $this->output->isDebug()->willReturn(true);
+        $mustRun = $this->process->mustRun(Argument::cetera())->willReturn($this->process->reveal());
+        $wrapCallback = $this->helper->wrapCallback(Argument::cetera())->willReturn(function (): void {
+        });
+
+        $this->runner->run($this->output->reveal(), []);
+
+        $isVeryVerbose->shouldHaveBeenCalledTimes(2);
+        $isDebug->shouldHaveBeenCalledOnce();
+        $mustRun->shouldHaveBeenCalledOnce();
+        $wrapCallback->shouldHaveBeenCalledOnce();
+        $this->process->isSuccessful()->shouldNotHaveBeenCalled();
+        $this->process->getCommandLine()->shouldNotHaveBeenCalled();
+        $this->output->write(Argument::cetera())->shouldNotHaveBeenCalled();
+        $this->formatter->start(Argument::cetera())->shouldNotHaveBeenCalled();
+        $this->formatter->stop(Argument::cetera())->shouldNotHaveBeenCalled();
+    }
+}

+ 34 - 13
module/Core/config/dependencies.config.php

@@ -15,6 +15,8 @@ return [
 
     'dependencies' => [
         'factories' => [
+            ErrorHandler\NotFoundTypeResolverMiddleware::class => ConfigAbstractFactory::class,
+            ErrorHandler\NotFoundTrackerMiddleware::class => ConfigAbstractFactory::class,
             ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
             ErrorHandler\NotFoundTemplateHandler::class => InvokableFactory::class,
 
@@ -24,16 +26,20 @@ return [
             Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
 
             Service\UrlShortener::class => ConfigAbstractFactory::class,
-            Service\VisitsTracker::class => ConfigAbstractFactory::class,
             Service\ShortUrlService::class => ConfigAbstractFactory::class,
-            Visit\VisitLocator::class => ConfigAbstractFactory::class,
-            Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
-            Tag\TagService::class => ConfigAbstractFactory::class,
             Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
             Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
             Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class,
+
+            Tag\TagService::class => ConfigAbstractFactory::class,
+
             Domain\DomainService::class => ConfigAbstractFactory::class,
 
+            Visit\VisitsTracker::class => ConfigAbstractFactory::class,
+            Visit\VisitLocator::class => ConfigAbstractFactory::class,
+            Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
+            Visit\Transformer\OrphanVisitDataTransformer::class => InvokableFactory::class,
+
             Util\UrlValidator::class => ConfigAbstractFactory::class,
             Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class,
             Util\RedirectResponseHelper::class => ConfigAbstractFactory::class,
@@ -43,6 +49,9 @@ return [
             Action\QrCodeAction::class => ConfigAbstractFactory::class,
 
             ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ConfigAbstractFactory::class,
+            ShortUrl\Helper\ShortUrlStringifier::class => ConfigAbstractFactory::class,
+            ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ConfigAbstractFactory::class,
+            ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class,
 
             Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
 
@@ -55,10 +64,11 @@ return [
     ],
 
     ConfigAbstractFactory::class => [
+        ErrorHandler\NotFoundTypeResolverMiddleware::class => ['config.router.base_path'],
+        ErrorHandler\NotFoundTrackerMiddleware::class => [Visit\VisitsTracker::class],
         ErrorHandler\NotFoundRedirectHandler::class => [
             NotFoundRedirectOptions::class,
             Util\RedirectResponseHelper::class,
-            'config.router.base_path',
         ],
 
         Options\AppOptions::class => ['config.app_options'],
@@ -67,17 +77,22 @@ return [
         Options\UrlShortenerOptions::class => ['config.url_shortener'],
 
         Service\UrlShortener::class => [
-            Util\UrlValidator::class,
+            ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
             'em',
             ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
             Service\ShortUrl\ShortCodeHelper::class,
         ],
-        Service\VisitsTracker::class => [
+        Visit\VisitsTracker::class => [
             'em',
             EventDispatcherInterface::class,
-            'config.url_shortener.anonymize_remote_addr',
+            Options\UrlShortenerOptions::class,
+        ],
+        Service\ShortUrlService::class => [
+            'em',
+            Service\ShortUrl\ShortUrlResolver::class,
+            ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
+            ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
         ],
-        Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
         Visit\VisitLocator::class => ['em'],
         Visit\VisitsStatsHelper::class => ['em'],
         Tag\TagService::class => ['em'],
@@ -96,26 +111,32 @@ return [
 
         Action\RedirectAction::class => [
             Service\ShortUrl\ShortUrlResolver::class,
-            Service\VisitsTracker::class,
+            Visit\VisitsTracker::class,
             Options\AppOptions::class,
             Util\RedirectResponseHelper::class,
             'Logger_Shlink',
         ],
         Action\PixelAction::class => [
             Service\ShortUrl\ShortUrlResolver::class,
-            Service\VisitsTracker::class,
+            Visit\VisitsTracker::class,
             Options\AppOptions::class,
             'Logger_Shlink',
         ],
         Action\QrCodeAction::class => [
             Service\ShortUrl\ShortUrlResolver::class,
-            'config.url_shortener.domain',
+            ShortUrl\Helper\ShortUrlStringifier::class,
             'Logger_Shlink',
         ],
 
         ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class => ['em'],
+        ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
+        ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class],
+        ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
 
-        Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
+        Mercure\MercureUpdatesGenerator::class => [
+            ShortUrl\Transformer\ShortUrlDataTransformer::class,
+            Visit\Transformer\OrphanVisitDataTransformer::class,
+        ],
 
         Importer\ImportedLinksProcessor::class => [
             'em',

+ 11 - 0
module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php

@@ -84,4 +84,15 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
             ->build();
 
     $builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
+
+    $builder->createField('title', Types::STRING)
+            ->columnName('title')
+            ->length(512)
+            ->nullable()
+            ->build();
+
+    $builder->createField('titleWasAutoResolved', Types::BOOLEAN)
+            ->columnName('title_was_auto_resolved')
+            ->option('default', false)
+            ->build();
 };

+ 12 - 1
module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php

@@ -47,11 +47,22 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
             ->build();
 
     $builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
-            ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
+            ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
             ->build();
 
     $builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
             ->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
             ->cascadePersist()
             ->build();
+
+    $builder->createField('visitedUrl', Types::STRING)
+            ->columnName('visited_url')
+            ->length(Visitor::VISITED_URL_MAX_LENGTH)
+            ->nullable()
+            ->build();
+
+    $builder->createField('type', Types::STRING)
+            ->columnName('type')
+            ->length(255)
+            ->build();
 };

+ 6 - 6
module/Core/config/event_dispatcher.config.php

@@ -20,28 +20,28 @@ return [
             ],
         ],
         'async' => [
-            EventDispatcher\Event\ShortUrlVisited::class => [
-                EventDispatcher\LocateShortUrlVisit::class,
+            EventDispatcher\Event\UrlVisited::class => [
+                EventDispatcher\LocateVisit::class,
             ],
         ],
     ],
 
     'dependencies' => [
         'factories' => [
-            EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
+            EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class,
             EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
             EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
         ],
 
         'delegators' => [
-            EventDispatcher\LocateShortUrlVisit::class => [
+            EventDispatcher\LocateVisit::class => [
                 EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
             ],
         ],
     ],
 
     ConfigAbstractFactory::class => [
-        EventDispatcher\LocateShortUrlVisit::class => [
+        EventDispatcher\LocateVisit::class => [
             IpLocationResolverInterface::class,
             'em',
             'Logger_Shlink',
@@ -53,7 +53,7 @@ return [
             'em',
             'Logger_Shlink',
             'config.url_shortener.visits_webhooks',
-            'config.url_shortener.domain',
+            ShortUrl\Transformer\ShortUrlDataTransformer::class,
             Options\AppOptions::class,
         ],
         EventDispatcher\NotifyVisitToMercure::class => [

+ 30 - 0
module/Core/functions/functions.php

@@ -9,12 +9,16 @@ use DateTimeInterface;
 use Fig\Http\Message\StatusCodeInterface;
 use Laminas\InputFilter\InputFilter;
 use PUGX\Shortid\Factory as ShortIdFactory;
+use Shlinkio\Shlink\Common\Util\DateRange;
 
 use function Functional\reduce_left;
 use function is_array;
+use function lcfirst;
 use function print_r;
 use function sprintf;
 use function str_repeat;
+use function str_replace;
+use function ucwords;
 
 const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15;
 const DEFAULT_SHORT_CODES_LENGTH = 5;
@@ -23,6 +27,7 @@ const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
 const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
 const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
 const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
+const TITLE_TAG_VALUE = '/<title[^>]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
 
 function generateRandomShortCode(int $length): string
 {
@@ -40,6 +45,26 @@ function parseDateFromQuery(array $query, string $dateName): ?Chronos
     return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]);
 }
 
+function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange
+{
+    $startDate = parseDateFromQuery($query, $startDateName);
+    $endDate = parseDateFromQuery($query, $endDateName);
+
+    if ($startDate === null && $endDate === null) {
+        return DateRange::emptyInstance();
+    }
+
+    if ($startDate !== null && $endDate !== null) {
+        return DateRange::withStartAndEndDate($startDate, $endDate);
+    }
+
+    if ($startDate !== null) {
+        return DateRange::withStartDate($startDate);
+    }
+
+    return DateRange::withEndDate($endDate);
+}
+
 /**
  * @param string|DateTimeInterface|Chronos|null $date
  */
@@ -97,3 +122,8 @@ function arrayToString(array $array, int $indentSize = 4): string
         );
     }, '');
 }
+
+function kebabCaseToCamelCase(string $name): string
+{
+    return lcfirst(str_replace(' ', '', ucwords(str_replace('-', ' ', $name))));
+}

+ 1 - 1
module/Core/src/Action/AbstractTrackingAction.php

@@ -20,7 +20,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Model\Visitor;
 use Shlinkio\Shlink\Core\Options\AppOptions;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
-use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
+use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
 
 use function array_key_exists;
 use function array_merge;

+ 25 - 10
module/Core/src/Action/QrCodeAction.php

@@ -16,6 +16,7 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
 use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
 use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
+use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
 
 class QrCodeAction implements MiddlewareInterface
 {
@@ -24,17 +25,17 @@ class QrCodeAction implements MiddlewareInterface
     private const MAX_SIZE = 1000;
 
     private ShortUrlResolverInterface $urlResolver;
-    private array $domainConfig;
+    private ShortUrlStringifierInterface $stringifier;
     private LoggerInterface $logger;
 
     public function __construct(
         ShortUrlResolverInterface $urlResolver,
-        array $domainConfig,
+        ShortUrlStringifierInterface $stringifier,
         ?LoggerInterface $logger = null
     ) {
         $this->urlResolver = $urlResolver;
-        $this->domainConfig = $domainConfig;
         $this->logger = $logger ?? new NullLogger();
+        $this->stringifier = $stringifier;
     }
 
     public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -49,12 +50,9 @@ class QrCodeAction implements MiddlewareInterface
         }
 
         $query = $request->getQueryParams();
-        // Size attribute is deprecated
-        $size = $this->normalizeSize((int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE));
-
-        $qrCode = new QrCode($shortUrl->toString($this->domainConfig));
-        $qrCode->setSize($size);
-        $qrCode->setMargin(0);
+        $qrCode = new QrCode($this->stringifier->stringify($shortUrl));
+        $qrCode->setSize($this->resolveSize($request, $query));
+        $qrCode->setMargin($this->resolveMargin($query));
 
         $format = $query['format'] ?? 'png';
         if ($format === 'svg') {
@@ -64,12 +62,29 @@ class QrCodeAction implements MiddlewareInterface
         return new QrCodeResponse($qrCode);
     }
 
-    private function normalizeSize(int $size): int
+    private function resolveSize(Request $request, array $query): int
     {
+        // Size attribute is deprecated. After v3.0.0, always use the query param instead
+        $size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE);
         if ($size < self::MIN_SIZE) {
             return self::MIN_SIZE;
         }
 
         return $size > self::MAX_SIZE ? self::MAX_SIZE : $size;
     }
+
+    private function resolveMargin(array $query): int
+    {
+        if (! isset($query['margin'])) {
+            return 0;
+        }
+
+        $margin = $query['margin'];
+        $intMargin = (int) $margin;
+        if ($margin !== (string) $intMargin) {
+            return 0;
+        }
+
+        return $intMargin < 0 ? 0 : $intMargin;
+    }
 }

+ 1 - 1
module/Core/src/Action/RedirectAction.php

@@ -11,8 +11,8 @@ use Psr\Http\Server\RequestHandlerInterface;
 use Psr\Log\LoggerInterface;
 use Shlinkio\Shlink\Core\Options;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
-use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
 use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
+use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
 
 class RedirectAction extends AbstractTrackingAction implements StatusCodeInterface
 {

+ 93 - 78
module/Core/src/Entity/ShortUrl.php

@@ -7,14 +7,13 @@ namespace Shlinkio\Shlink\Core\Entity;
 use Cake\Chronos\Chronos;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
-use Laminas\Diactoros\Uri;
 use Shlinkio\Shlink\Common\Entity\AbstractEntity;
 use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
 use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
 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\ShortUrlMetaInputFilter;
+use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
 use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
 use Shlinkio\Shlink\Rest\Entity\ApiKey;
 
@@ -39,27 +38,46 @@ class ShortUrl extends AbstractEntity
     private ?string $importSource = null;
     private ?string $importOriginalShortCode = null;
     private ?ApiKey $authorApiKey = null;
+    private ?string $title = null;
+    private bool $titleWasAutoResolved = false;
 
-    public function __construct(
-        string $longUrl,
-        ?ShortUrlMeta $meta = null,
+    private function __construct()
+    {
+    }
+
+    public static function createEmpty(): self
+    {
+        return self::fromMeta(ShortUrlMeta::createEmpty());
+    }
+
+    public static function withLongUrl(string $longUrl): self
+    {
+        return self::fromMeta(ShortUrlMeta::fromRawData([ShortUrlInputFilter::LONG_URL => $longUrl]));
+    }
+
+    public static function fromMeta(
+        ShortUrlMeta $meta,
         ?ShortUrlRelationResolverInterface $relationResolver = null
-    ) {
-        $meta = $meta ?? ShortUrlMeta::createEmpty();
+    ): self {
+        $instance = new self();
         $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
 
-        $this->longUrl = $longUrl;
-        $this->dateCreated = Chronos::now();
-        $this->visits = new ArrayCollection();
-        $this->tags = new ArrayCollection();
-        $this->validSince = $meta->getValidSince();
-        $this->validUntil = $meta->getValidUntil();
-        $this->maxVisits = $meta->getMaxVisits();
-        $this->customSlugWasProvided = $meta->hasCustomSlug();
-        $this->shortCodeLength = $meta->getShortCodeLength();
-        $this->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($this->shortCodeLength);
-        $this->domain = $relationResolver->resolveDomain($meta->getDomain());
-        $this->authorApiKey = $meta->getApiKey();
+        $instance->longUrl = $meta->getLongUrl();
+        $instance->dateCreated = Chronos::now();
+        $instance->visits = new ArrayCollection();
+        $instance->tags = $relationResolver->resolveTags($meta->getTags());
+        $instance->validSince = $meta->getValidSince();
+        $instance->validUntil = $meta->getValidUntil();
+        $instance->maxVisits = $meta->getMaxVisits();
+        $instance->customSlugWasProvided = $meta->hasCustomSlug();
+        $instance->shortCodeLength = $meta->getShortCodeLength();
+        $instance->shortCode = $meta->getCustomSlug() ?? generateRandomShortCode($instance->shortCodeLength);
+        $instance->domain = $relationResolver->resolveDomain($meta->getDomain());
+        $instance->authorApiKey = $meta->getApiKey();
+        $instance->title = $meta->getTitle();
+        $instance->titleWasAutoResolved = $meta->titleWasAutoResolved();
+
+        return $instance;
     }
 
     public static function fromImport(
@@ -68,14 +86,17 @@ class ShortUrl extends AbstractEntity
         ?ShortUrlRelationResolverInterface $relationResolver = null
     ): self {
         $meta = [
-            ShortUrlMetaInputFilter::DOMAIN => $url->domain(),
-            ShortUrlMetaInputFilter::VALIDATE_URL => false,
+            ShortUrlInputFilter::LONG_URL => $url->longUrl(),
+            ShortUrlInputFilter::DOMAIN => $url->domain(),
+            ShortUrlInputFilter::TAGS => $url->tags(),
+            ShortUrlInputFilter::TITLE => $url->title(),
+            ShortUrlInputFilter::VALIDATE_URL => false,
         ];
         if ($importShortCode) {
-            $meta[ShortUrlMetaInputFilter::CUSTOM_SLUG] = $url->shortCode();
+            $meta[ShortUrlInputFilter::CUSTOM_SLUG] = $url->shortCode();
         }
 
-        $instance = new self($url->longUrl(), ShortUrlMeta::fromRawData($meta), $relationResolver);
+        $instance = self::fromMeta(ShortUrlMeta::fromRawData($meta), $relationResolver);
         $instance->importSource = $url->source();
         $instance->importOriginalShortCode = $url->shortCode();
         $instance->dateCreated = Chronos::instance($url->createdAt());
@@ -111,28 +132,68 @@ class ShortUrl extends AbstractEntity
         return $this->tags;
     }
 
+    public function getValidSince(): ?Chronos
+    {
+        return $this->validSince;
+    }
+
+    public function getValidUntil(): ?Chronos
+    {
+        return $this->validUntil;
+    }
+
+    public function getVisitsCount(): int
+    {
+        return count($this->visits);
+    }
+
     /**
-     * @param Collection|Tag[] $tags
+     * @param Collection|Visit[] $visits
+     * @internal
      */
-    public function setTags(Collection $tags): self
+    public function setVisits(Collection $visits): self
     {
-        $this->tags = $tags;
+        $this->visits = $visits;
         return $this;
     }
 
-    public function update(ShortUrlEdit $shortUrlEdit): void
+    public function getMaxVisits(): ?int
+    {
+        return $this->maxVisits;
+    }
+
+    public function getTitle(): ?string
     {
-        if ($shortUrlEdit->hasValidSince()) {
+        return $this->title;
+    }
+
+    public function update(
+        ShortUrlEdit $shortUrlEdit,
+        ?ShortUrlRelationResolverInterface $relationResolver = null
+    ): void {
+        if ($shortUrlEdit->validSinceWasProvided()) {
             $this->validSince = $shortUrlEdit->validSince();
         }
-        if ($shortUrlEdit->hasValidUntil()) {
+        if ($shortUrlEdit->validUntilWasProvided()) {
             $this->validUntil = $shortUrlEdit->validUntil();
         }
-        if ($shortUrlEdit->hasMaxVisits()) {
+        if ($shortUrlEdit->maxVisitsWasProvided()) {
             $this->maxVisits = $shortUrlEdit->maxVisits();
         }
-        if ($shortUrlEdit->hasLongUrl()) {
-            $this->longUrl = $shortUrlEdit->longUrl();
+        if ($shortUrlEdit->longUrlWasProvided()) {
+            $this->longUrl = $shortUrlEdit->longUrl() ?? $this->longUrl;
+        }
+        if ($shortUrlEdit->tagsWereProvided()) {
+            $relationResolver = $relationResolver ?? new SimpleShortUrlRelationResolver();
+            $this->tags = $relationResolver->resolveTags($shortUrlEdit->tags());
+        }
+        if (
+            $this->title === null
+            || $shortUrlEdit->titleWasProvided()
+            || ($this->titleWasAutoResolved && $shortUrlEdit->titleWasAutoResolved())
+        ) {
+            $this->title = $shortUrlEdit->title();
+            $this->titleWasAutoResolved = $shortUrlEdit->titleWasAutoResolved();
         }
     }
 
@@ -154,36 +215,6 @@ class ShortUrl extends AbstractEntity
         $this->shortCode = generateRandomShortCode($this->shortCodeLength);
     }
 
-    public function getValidSince(): ?Chronos
-    {
-        return $this->validSince;
-    }
-
-    public function getValidUntil(): ?Chronos
-    {
-        return $this->validUntil;
-    }
-
-    public function getVisitsCount(): int
-    {
-        return count($this->visits);
-    }
-
-    /**
-     * @param Collection|Visit[] $visits
-     * @internal
-     */
-    public function setVisits(Collection $visits): self
-    {
-        $this->visits = $visits;
-        return $this;
-    }
-
-    public function getMaxVisits(): ?int
-    {
-        return $this->maxVisits;
-    }
-
     public function isEnabled(): bool
     {
         $maxVisitsReached = $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
@@ -204,20 +235,4 @@ class ShortUrl extends AbstractEntity
 
         return true;
     }
-
-    public function toString(array $domainConfig): string
-    {
-        return (string) (new Uri())->withPath($this->shortCode)
-                                   ->withScheme($domainConfig['schema'] ?? 'http')
-                                   ->withHost($this->resolveDomain($domainConfig['hostname'] ?? ''));
-    }
-
-    private function resolveDomain(string $fallback = ''): string
-    {
-        if ($this->domain === null) {
-            return $fallback;
-        }
-
-        return $this->domain->getAuthority();
-    }
 }

+ 49 - 12
module/Core/src/Entity/Visit.php

@@ -14,20 +14,29 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface;
 
 class Visit extends AbstractEntity implements JsonSerializable
 {
+    public const TYPE_VALID_SHORT_URL = 'valid_short_url';
+    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 $remoteAddr;
+    private ?string $visitedUrl;
     private string $userAgent;
-    private ShortUrl $shortUrl;
+    private string $type;
+    private ?ShortUrl $shortUrl;
     private ?VisitLocation $visitLocation = null;
 
-    public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null)
+    private function __construct(?ShortUrl $shortUrl, Visitor $visitor, string $type, bool $anonymize = true)
     {
         $this->shortUrl = $shortUrl;
-        $this->date = $date ?? Chronos::now();
+        $this->date = Chronos::now();
         $this->userAgent = $visitor->getUserAgent();
         $this->referer = $visitor->getReferer();
         $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
+        $this->visitedUrl = $visitor->getVisitedUrl();
+        $this->type = $type;
     }
 
     private function processAddress(bool $anonymize, ?string $address): ?string
@@ -44,6 +53,26 @@ class Visit extends AbstractEntity implements JsonSerializable
         }
     }
 
+    public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self
+    {
+        return new self($shortUrl, $visitor, self::TYPE_VALID_SHORT_URL, $anonymize);
+    }
+
+    public static function forBasePath(Visitor $visitor, bool $anonymize = true): self
+    {
+        return new self(null, $visitor, self::TYPE_BASE_URL, $anonymize);
+    }
+
+    public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self
+    {
+        return new self(null, $visitor, self::TYPE_INVALID_SHORT_URL, $anonymize);
+    }
+
+    public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self
+    {
+        return new self(null, $visitor, self::TYPE_REGULAR_404, $anonymize);
+    }
+
     public function getRemoteAddr(): ?string
     {
         return $this->remoteAddr;
@@ -54,7 +83,7 @@ class Visit extends AbstractEntity implements JsonSerializable
         return ! empty($this->remoteAddr);
     }
 
-    public function getShortUrl(): ShortUrl
+    public function getShortUrl(): ?ShortUrl
     {
         return $this->shortUrl;
     }
@@ -75,13 +104,21 @@ class Visit extends AbstractEntity implements JsonSerializable
         return $this;
     }
 
-    /**
-     * Specify data which should be serialized to JSON
-     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
-     * @return array data which can be serialized by <b>json_encode</b>,
-     * which is a value of any type other than a resource.
-     * @since 5.4.0
-     */
+    public function isOrphan(): bool
+    {
+        return $this->shortUrl === null;
+    }
+
+    public function visitedUrl(): ?string
+    {
+        return $this->visitedUrl;
+    }
+
+    public function type(): string
+    {
+        return $this->type;
+    }
+
     public function jsonSerialize(): array
     {
         return [

+ 57 - 0
module/Core/src/ErrorHandler/Model/NotFoundType.php

@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+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 function rtrim;
+
+class NotFoundType
+{
+    private string $type;
+
+    private function __construct(string $type)
+    {
+        $this->type = $type;
+    }
+
+    public static function fromRequest(ServerRequestInterface $request, string $basePath): self
+    {
+        $isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath;
+        if ($isBaseUrl) {
+            return new self(Visit::TYPE_BASE_URL);
+        }
+
+        /** @var RouteResult $routeResult */
+        $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
+        if ($routeResult->isFailure()) {
+            return new self(Visit::TYPE_REGULAR_404);
+        }
+
+        if ($routeResult->getMatchedRouteName() === RedirectAction::class) {
+            return new self(Visit::TYPE_INVALID_SHORT_URL);
+        }
+
+        return new self(self::class);
+    }
+
+    public function isBaseUrl(): bool
+    {
+        return $this->type === Visit::TYPE_BASE_URL;
+    }
+
+    public function isRegularNotFound(): bool
+    {
+        return $this->type === Visit::TYPE_REGULAR_404;
+    }
+
+    public function isInvalidShortUrl(): bool
+    {
+        return $this->type === Visit::TYPE_INVALID_SHORT_URL;
+    }
+}

+ 8 - 27
module/Core/src/ErrorHandler/NotFoundRedirectHandler.php

@@ -4,67 +4,48 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\ErrorHandler;
 
-use Mezzio\Router\RouteResult;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
-use Psr\Http\Message\UriInterface;
 use Psr\Http\Server\MiddlewareInterface;
 use Psr\Http\Server\RequestHandlerInterface;
-use Shlinkio\Shlink\Core\Action\RedirectAction;
+use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
 use Shlinkio\Shlink\Core\Options;
 use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
 
-use function rtrim;
-
 class NotFoundRedirectHandler implements MiddlewareInterface
 {
     private Options\NotFoundRedirectOptions $redirectOptions;
     private RedirectResponseHelperInterface $redirectResponseHelper;
-    private string $shlinkBasePath;
 
     public function __construct(
         Options\NotFoundRedirectOptions $redirectOptions,
-        RedirectResponseHelperInterface $redirectResponseHelper,
-        string $shlinkBasePath
+        RedirectResponseHelperInterface $redirectResponseHelper
     ) {
         $this->redirectOptions = $redirectOptions;
-        $this->shlinkBasePath = $shlinkBasePath;
         $this->redirectResponseHelper = $redirectResponseHelper;
     }
 
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
     {
-        /** @var RouteResult $routeResult */
-        $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
-        $redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri());
-
-        return $redirectResponse ?? $handler->handle($request);
-    }
-
-    private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface
-    {
-        $isBaseUrl = rtrim($uri->getPath(), '/') === $this->shlinkBasePath;
+        /** @var NotFoundType $notFoundType */
+        $notFoundType = $request->getAttribute(NotFoundType::class);
 
-        if ($isBaseUrl && $this->redirectOptions->hasBaseUrlRedirect()) {
+        if ($notFoundType->isBaseUrl() && $this->redirectOptions->hasBaseUrlRedirect()) {
             return $this->redirectResponseHelper->buildRedirectResponse($this->redirectOptions->getBaseUrlRedirect());
         }
 
-        if (!$isBaseUrl && $routeResult->isFailure() && $this->redirectOptions->hasRegular404Redirect()) {
+        if ($notFoundType->isRegularNotFound() && $this->redirectOptions->hasRegular404Redirect()) {
             return $this->redirectResponseHelper->buildRedirectResponse(
                 $this->redirectOptions->getRegular404Redirect(),
             );
         }
 
-        if (
-            $routeResult->isSuccess() &&
-            $routeResult->getMatchedRouteName() === RedirectAction::class &&
-            $this->redirectOptions->hasInvalidShortUrlRedirect()
-        ) {
+        if ($notFoundType->isInvalidShortUrl() && $this->redirectOptions->hasInvalidShortUrlRedirect()) {
             return $this->redirectResponseHelper->buildRedirectResponse(
                 $this->redirectOptions->getInvalidShortUrlRedirect(),
             );
         }
 
-        return null;
+        return $handler->handle($request);
     }
 }

+ 4 - 4
module/Core/src/ErrorHandler/NotFoundTemplateHandler.php

@@ -7,10 +7,10 @@ namespace Shlinkio\Shlink\Core\ErrorHandler;
 use Closure;
 use Fig\Http\Message\StatusCodeInterface;
 use Laminas\Diactoros\Response;
-use Mezzio\Router\RouteResult;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\RequestHandlerInterface;
+use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
 
 use function file_get_contents;
 use function sprintf;
@@ -29,11 +29,11 @@ class NotFoundTemplateHandler implements RequestHandlerInterface
 
     public function handle(ServerRequestInterface $request): ResponseInterface
     {
-        /** @var RouteResult $routeResult */
-        $routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null);
+        /** @var NotFoundType $notFoundType */
+        $notFoundType = $request->getAttribute(NotFoundType::class);
         $status = StatusCodeInterface::STATUS_NOT_FOUND;
 
-        $template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
+        $template = $notFoundType->isInvalidShortUrl() ? self::INVALID_SHORT_CODE_TEMPLATE : self::NOT_FOUND_TEMPLATE;
         $templateContent = ($this->readFile)(sprintf('%s/%s', self::TEMPLATES_BASE_DIR, $template));
         return new Response\HtmlResponse($templateContent, $status);
     }

+ 40 - 0
module/Core/src/ErrorHandler/NotFoundTrackerMiddleware.php

@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\Core\ErrorHandler;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
+use Shlinkio\Shlink\Core\Model\Visitor;
+use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
+
+class NotFoundTrackerMiddleware implements MiddlewareInterface
+{
+    private VisitsTrackerInterface $visitsTracker;
+
+    public function __construct(VisitsTrackerInterface $visitsTracker)
+    {
+        $this->visitsTracker = $visitsTracker;
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        /** @var NotFoundType $notFoundType */
+        $notFoundType = $request->getAttribute(NotFoundType::class);
+        $visitor = Visitor::fromRequest($request);
+
+        if ($notFoundType->isBaseUrl()) {
+            $this->visitsTracker->trackBaseUrlVisit($visitor);
+        } elseif ($notFoundType->isRegularNotFound()) {
+            $this->visitsTracker->trackRegularNotFoundVisit($visitor);
+        } elseif ($notFoundType->isInvalidShortUrl()) {
+            $this->visitsTracker->trackInvalidShortUrlVisit($visitor);
+        }
+
+        return $handler->handle($request);
+    }
+}

+ 27 - 0
module/Core/src/ErrorHandler/NotFoundTypeResolverMiddleware.php

@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\Core\ErrorHandler;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
+
+class NotFoundTypeResolverMiddleware implements MiddlewareInterface
+{
+    private string $shlinkBasePath;
+
+    public function __construct(string $shlinkBasePath)
+    {
+        $this->shlinkBasePath = $shlinkBasePath;
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $notFoundType = NotFoundType::fromRequest($request, $this->shlinkBasePath);
+        return $handler->handle($request->withAttribute(NotFoundType::class, $notFoundType));
+    }
+}

+ 1 - 1
module/Core/src/EventDispatcher/Event/ShortUrlVisited.php → module/Core/src/EventDispatcher/Event/UrlVisited.php

@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
 
-final class ShortUrlVisited extends AbstractVisitEvent
+final class UrlVisited extends AbstractVisitEvent
 {
     private ?string $originalIpAddress;
 

+ 3 - 3
module/Core/src/EventDispatcher/LocateShortUrlVisit.php → module/Core/src/EventDispatcher/LocateVisit.php

@@ -11,7 +11,7 @@ use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
 use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
 use Shlinkio\Shlink\Core\Entity\Visit;
 use Shlinkio\Shlink\Core\Entity\VisitLocation;
-use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlVisited;
+use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
 use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
 use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
 use Shlinkio\Shlink\IpGeolocation\Model\Location;
@@ -19,7 +19,7 @@ use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
 
 use function sprintf;
 
-class LocateShortUrlVisit
+class LocateVisit
 {
     private IpLocationResolverInterface $ipLocationResolver;
     private EntityManagerInterface $em;
@@ -41,7 +41,7 @@ class LocateShortUrlVisit
         $this->eventDispatcher = $eventDispatcher;
     }
 
-    public function __invoke(ShortUrlVisited $shortUrlVisited): void
+    public function __invoke(UrlVisited $shortUrlVisited): void
     {
         $visitId = $shortUrlVisited->visitId();
 

+ 19 - 2
module/Core/src/EventDispatcher/NotifyVisitToMercure.php

@@ -10,8 +10,11 @@ use Shlinkio\Shlink\Core\Entity\Visit;
 use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
 use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
 use Symfony\Component\Mercure\PublisherInterface;
+use Symfony\Component\Mercure\Update;
 use Throwable;
 
+use function Functional\each;
+
 class NotifyVisitToMercure
 {
     private PublisherInterface $publisher;
@@ -45,12 +48,26 @@ class NotifyVisitToMercure
         }
 
         try {
-            ($this->publisher)($this->updatesGenerator->newShortUrlVisitUpdate($visit));
-            ($this->publisher)($this->updatesGenerator->newVisitUpdate($visit));
+            each($this->determineUpdatesForVisit($visit), fn (Update $update) => ($this->publisher)($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),
+        ];
+    }
 }

+ 6 - 6
module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php

@@ -10,17 +10,17 @@ use Fig\Http\Message\RequestMethodInterface;
 use GuzzleHttp\ClientInterface;
 use GuzzleHttp\Promise\Promise;
 use GuzzleHttp\Promise\PromiseInterface;
+use GuzzleHttp\Promise\Utils;
 use GuzzleHttp\RequestOptions;
 use Psr\Log\LoggerInterface;
+use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
 use Shlinkio\Shlink\Core\Entity\Visit;
 use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
 use Shlinkio\Shlink\Core\Options\AppOptions;
-use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
 use Throwable;
 
 use function Functional\map;
 use function Functional\partial_left;
-use function GuzzleHttp\Promise\settle;
 
 class NotifyVisitToWebHooks
 {
@@ -29,7 +29,7 @@ class NotifyVisitToWebHooks
     private LoggerInterface $logger;
     /** @var string[] */
     private array $webhooks;
-    private ShortUrlDataTransformer $transformer;
+    private DataTransformerInterface $transformer;
     private AppOptions $appOptions;
 
     public function __construct(
@@ -37,14 +37,14 @@ class NotifyVisitToWebHooks
         EntityManagerInterface $em,
         LoggerInterface $logger,
         array $webhooks,
-        array $domainConfig,
+        DataTransformerInterface $transformer,
         AppOptions $appOptions
     ) {
         $this->httpClient = $httpClient;
         $this->em = $em;
         $this->logger = $logger;
         $this->webhooks = $webhooks;
-        $this->transformer = new ShortUrlDataTransformer($domainConfig);
+        $this->transformer = $transformer;
         $this->appOptions = $appOptions;
     }
 
@@ -69,7 +69,7 @@ class NotifyVisitToWebHooks
         $requestPromises = $this->performRequests($requestOptions, $visitId);
 
         // Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error.
-        settle($requestPromises)->wait();
+        Utils::settle($requestPromises)->wait();
     }
 
     private function buildRequestOptions(Visit $visit): array

+ 0 - 5
module/Core/src/Importer/ImportedLinksProcessor.php

@@ -10,7 +10,6 @@ use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
 use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
 use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
 use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
-use Shlinkio\Shlink\Core\Util\TagManagerTrait;
 use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
 use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
 use Symfony\Component\Console\Style\StyleInterface;
@@ -19,8 +18,6 @@ use function sprintf;
 
 class ImportedLinksProcessor implements ImportedLinksProcessorInterface
 {
-    use TagManagerTrait;
-
     private EntityManagerInterface $em;
     private ShortUrlRelationResolverInterface $relationResolver;
     private ShortCodeHelperInterface $shortCodeHelper;
@@ -59,8 +56,6 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
             }
 
             $shortUrl = ShortUrl::fromImport($url, $importShortCodes, $this->relationResolver);
-            $shortUrl->setTags($this->tagNamesToEntities($this->em, $url->tags()));
-
             if (! $this->handleShortCodeUniqueness($url, $shortUrl, $io, $importShortCodes)) {
                 continue;
             }

+ 19 - 7
module/Core/src/Mercure/MercureUpdatesGenerator.php

@@ -4,8 +4,8 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\Mercure;
 
+use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
 use Shlinkio\Shlink\Core\Entity\Visit;
-use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
 use Symfony\Component\Mercure\Update;
 
 use function json_encode;
@@ -16,29 +16,41 @@ use const JSON_THROW_ON_ERROR;
 final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
 {
     private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit';
+    private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit';
 
-    private ShortUrlDataTransformer $transformer;
+    private DataTransformerInterface $shortUrlTransformer;
+    private DataTransformerInterface $orphanVisitTransformer;
 
-    public function __construct(array $domainConfig)
-    {
-        $this->transformer = new ShortUrlDataTransformer($domainConfig);
+    public function __construct(
+        DataTransformerInterface $shortUrlTransformer,
+        DataTransformerInterface $orphanVisitTransformer
+    ) {
+        $this->shortUrlTransformer = $shortUrlTransformer;
+        $this->orphanVisitTransformer = $orphanVisitTransformer;
     }
 
     public function newVisitUpdate(Visit $visit): Update
     {
         return new Update(self::NEW_VISIT_TOPIC, $this->serialize([
-            'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
+            'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()),
             'visit' => $visit,
         ]));
     }
 
+    public function newOrphanVisitUpdate(Visit $visit): Update
+    {
+        return new Update(self::NEW_ORPHAN_VISIT_TOPIC, $this->serialize([
+            '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, $this->serialize([
-            'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
+            'shortUrl' => $this->shortUrlTransformer->transform($shortUrl),
             'visit' => $visit,
         ]));
     }

+ 2 - 0
module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php

@@ -11,5 +11,7 @@ interface MercureUpdatesGeneratorInterface
 {
     public function newVisitUpdate(Visit $visit): Update;
 
+    public function newOrphanVisitUpdate(Visit $visit): Update;
+
     public function newShortUrlVisitUpdate(Visit $visit): Update;
 }

+ 0 - 37
module/Core/src/Model/CreateShortUrlData.php

@@ -1,37 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shlinkio\Shlink\Core\Model;
-
-final class CreateShortUrlData
-{
-    private string $longUrl;
-    private array $tags;
-    private ShortUrlMeta $meta;
-
-    public function __construct(string $longUrl, array $tags = [], ?ShortUrlMeta $meta = null)
-    {
-        $this->longUrl = $longUrl;
-        $this->tags = $tags;
-        $this->meta = $meta ?? ShortUrlMeta::createEmpty();
-    }
-
-    public function getLongUrl(): string
-    {
-        return $this->longUrl;
-    }
-
-    /**
-     * @return string[]
-     */
-    public function getTags(): array
-    {
-        return $this->tags;
-    }
-
-    public function getMeta(): ShortUrlMeta
-    {
-        return $this->meta;
-    }
-}

+ 74 - 18
module/Core/src/Model/ShortUrlEdit.php

@@ -6,14 +6,15 @@ namespace Shlinkio\Shlink\Core\Model;
 
 use Cake\Chronos\Chronos;
 use Shlinkio\Shlink\Core\Exception\ValidationException;
-use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
+use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
+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;
 
-final class ShortUrlEdit
+final class ShortUrlEdit implements TitleResolutionModelInterface
 {
     private bool $longUrlPropWasProvided = false;
     private ?string $longUrl = null;
@@ -23,9 +24,13 @@ final class ShortUrlEdit
     private ?Chronos $validUntil = null;
     private bool $maxVisitsPropWasProvided = false;
     private ?int $maxVisits = null;
+    private bool $tagsPropWasProvided = false;
+    private array $tags = [];
+    private bool $titlePropWasProvided = false;
+    private ?string $title = null;
+    private bool $titleWasAutoResolved = false;
     private ?bool $validateUrl = null;
 
-    // Enforce named constructors
     private function __construct()
     {
     }
@@ -45,21 +50,25 @@ final class ShortUrlEdit
      */
     private function validateAndInit(array $data): void
     {
-        $inputFilter = new ShortUrlMetaInputFilter($data);
+        $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data);
         if (! $inputFilter->isValid()) {
             throw ValidationException::fromInputFilter($inputFilter);
         }
 
-        $this->longUrlPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::LONG_URL, $data);
-        $this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
-        $this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
-        $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
-
-        $this->longUrl = $inputFilter->getValue(ShortUrlMetaInputFilter::LONG_URL);
-        $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
-        $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
-        $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
-        $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL);
+        $this->longUrlPropWasProvided = array_key_exists(ShortUrlInputFilter::LONG_URL, $data);
+        $this->validSincePropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data);
+        $this->validUntilPropWasProvided = array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data);
+        $this->maxVisitsPropWasProvided = array_key_exists(ShortUrlInputFilter::MAX_VISITS, $data);
+        $this->tagsPropWasProvided = array_key_exists(ShortUrlInputFilter::TAGS, $data);
+        $this->titlePropWasProvided = array_key_exists(ShortUrlInputFilter::TITLE, $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->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS);
+        $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL);
+        $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
+        $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
     }
 
     public function longUrl(): ?string
@@ -67,7 +76,12 @@ final class ShortUrlEdit
         return $this->longUrl;
     }
 
-    public function hasLongUrl(): bool
+    public function getLongUrl(): string
+    {
+        return $this->longUrl() ?? '';
+    }
+
+    public function longUrlWasProvided(): bool
     {
         return $this->longUrlPropWasProvided && $this->longUrl !== null;
     }
@@ -77,7 +91,7 @@ final class ShortUrlEdit
         return $this->validSince;
     }
 
-    public function hasValidSince(): bool
+    public function validSinceWasProvided(): bool
     {
         return $this->validSincePropWasProvided;
     }
@@ -87,7 +101,7 @@ final class ShortUrlEdit
         return $this->validUntil;
     }
 
-    public function hasValidUntil(): bool
+    public function validUntilWasProvided(): bool
     {
         return $this->validUntilPropWasProvided;
     }
@@ -97,11 +111,53 @@ final class ShortUrlEdit
         return $this->maxVisits;
     }
 
-    public function hasMaxVisits(): bool
+    public function maxVisitsWasProvided(): bool
     {
         return $this->maxVisitsPropWasProvided;
     }
 
+    /**
+     * @return string[]
+     */
+    public function tags(): array
+    {
+        return $this->tags;
+    }
+
+    public function tagsWereProvided(): bool
+    {
+        return $this->tagsPropWasProvided;
+    }
+
+    public function title(): ?string
+    {
+        return $this->title;
+    }
+
+    public function titleWasProvided(): bool
+    {
+        return $this->titlePropWasProvided;
+    }
+
+    public function hasTitle(): bool
+    {
+        return $this->titleWasProvided();
+    }
+
+    public function titleWasAutoResolved(): bool
+    {
+        return $this->titleWasAutoResolved;
+    }
+
+    public function withResolvedTitle(string $title): self
+    {
+        $copy = clone $this;
+        $copy->title = $title;
+        $copy->titleWasAutoResolved = true;
+
+        return $copy;
+    }
+
     public function doValidateUrl(): ?bool
     {
         return $this->validateUrl;

+ 62 - 14
module/Core/src/Model/ShortUrlMeta.php

@@ -6,7 +6,8 @@ namespace Shlinkio\Shlink\Core\Model;
 
 use Cake\Chronos\Chronos;
 use Shlinkio\Shlink\Core\Exception\ValidationException;
-use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
+use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface;
+use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
 use Shlinkio\Shlink\Rest\Entity\ApiKey;
 
 use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter;
@@ -15,8 +16,9 @@ use function Shlinkio\Shlink\Core\parseDateField;
 
 use const Shlinkio\Shlink\Core\DEFAULT_SHORT_CODES_LENGTH;
 
-final class ShortUrlMeta
+final class ShortUrlMeta implements TitleResolutionModelInterface
 {
+    private string $longUrl;
     private ?Chronos $validSince = null;
     private ?Chronos $validUntil = null;
     private ?string $customSlug = null;
@@ -26,15 +28,20 @@ final class ShortUrlMeta
     private int $shortCodeLength = 5;
     private ?bool $validateUrl = null;
     private ?ApiKey $apiKey = null;
+    private array $tags = [];
+    private ?string $title = null;
+    private bool $titleWasAutoResolved = false;
 
-    // Enforce named constructors
     private function __construct()
     {
     }
 
     public static function createEmpty(): self
     {
-        return new self();
+        $instance = new self();
+        $instance->longUrl = '';
+
+        return $instance;
     }
 
     /**
@@ -44,6 +51,7 @@ final class ShortUrlMeta
     {
         $instance = new self();
         $instance->validateAndInit($data);
+
         return $instance;
     }
 
@@ -52,23 +60,31 @@ final class ShortUrlMeta
      */
     private function validateAndInit(array $data): void
     {
-        $inputFilter = new ShortUrlMetaInputFilter($data);
+        $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data);
         if (! $inputFilter->isValid()) {
             throw ValidationException::fromInputFilter($inputFilter);
         }
 
-        $this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
-        $this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
-        $this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
-        $this->maxVisits = getOptionalIntFromInputFilter($inputFilter, ShortUrlMetaInputFilter::MAX_VISITS);
-        $this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
-        $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlMetaInputFilter::VALIDATE_URL);
-        $this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
+        $this->longUrl = $inputFilter->getValue(ShortUrlInputFilter::LONG_URL);
+        $this->validSince = parseDateField($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE));
+        $this->validUntil = parseDateField($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);
+        $this->validateUrl = getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL);
+        $this->domain = $inputFilter->getValue(ShortUrlInputFilter::DOMAIN);
         $this->shortCodeLength = getOptionalIntFromInputFilter(
             $inputFilter,
-            ShortUrlMetaInputFilter::SHORT_CODE_LENGTH,
+            ShortUrlInputFilter::SHORT_CODE_LENGTH,
         ) ?? DEFAULT_SHORT_CODES_LENGTH;
-        $this->apiKey = $inputFilter->getValue(ShortUrlMetaInputFilter::API_KEY);
+        $this->apiKey = $inputFilter->getValue(ShortUrlInputFilter::API_KEY);
+        $this->tags = $inputFilter->getValue(ShortUrlInputFilter::TAGS);
+        $this->title = $inputFilter->getValue(ShortUrlInputFilter::TITLE);
+    }
+
+    public function getLongUrl(): string
+    {
+        return $this->longUrl;
     }
 
     public function getValidSince(): ?Chronos
@@ -140,4 +156,36 @@ final class ShortUrlMeta
     {
         return $this->apiKey;
     }
+
+    /**
+     * @return string[]
+     */
+    public function getTags(): array
+    {
+        return $this->tags;
+    }
+
+    public function getTitle(): ?string
+    {
+        return $this->title;
+    }
+
+    public function hasTitle(): bool
+    {
+        return $this->title !== null;
+    }
+
+    public function titleWasAutoResolved(): bool
+    {
+        return $this->titleWasAutoResolved;
+    }
+
+    public function withResolvedTitle(string $title): self
+    {
+        $copy = clone $this;
+        $copy->title = $title;
+        $copy->titleWasAutoResolved = true;
+
+        return $copy;
+    }
 }

+ 4 - 3
module/Core/src/Model/ShortUrlsOrdering.php

@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
 
 use Shlinkio\Shlink\Core\Exception\ValidationException;
 
+use function array_pad;
 use function explode;
 use function is_array;
 use function is_string;
@@ -50,9 +51,9 @@ final class ShortUrlsOrdering
 
         /** @var string|array $orderBy */
         if (! $isArray) {
-            $parts = explode('-', $orderBy);
-            $this->orderField = $parts[0];
-            $this->orderDirection = $parts[1] ?? self::DEFAULT_ORDER_DIRECTION;
+            [$field, $dir] = array_pad(explode('-', $orderBy), 2, null);
+            $this->orderField = $field;
+            $this->orderDirection = $dir ?? self::DEFAULT_ORDER_DIRECTION;
         } else {
             $this->orderField = key($orderBy);
             $this->orderDirection = $orderBy[$this->orderField];

+ 11 - 2
module/Core/src/Model/Visitor.php

@@ -14,15 +14,18 @@ final class Visitor
     public const USER_AGENT_MAX_LENGTH = 512;
     public const REFERER_MAX_LENGTH = 1024;
     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 function __construct(string $userAgent, string $referer, ?string $remoteAddress)
+    public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl)
     {
         $this->userAgent = $this->cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH);
         $this->referer = $this->cropToLength($referer, self::REFERER_MAX_LENGTH);
+        $this->visitedUrl = $this->cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH);
         $this->remoteAddress = $this->cropToLength($remoteAddress, self::REMOTE_ADDRESS_MAX_LENGTH);
     }
 
@@ -37,12 +40,13 @@ final class Visitor
             $request->getHeaderLine('User-Agent'),
             $request->getHeaderLine('Referer'),
             $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
+            $request->getUri()->__toString(),
         );
     }
 
     public static function emptyInstance(): self
     {
-        return new self('', '', null);
+        return new self('', '', null, '');
     }
 
     public function getUserAgent(): string
@@ -59,4 +63,9 @@ final class Visitor
     {
         return $this->remoteAddress;
     }
+
+    public function getVisitedUrl(): string
+    {
+        return $this->visitedUrl;
+    }
 }

+ 2 - 2
module/Core/src/Model/VisitsParams.php

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
 
 use Shlinkio\Shlink\Common\Util\DateRange;
 
-use function Shlinkio\Shlink\Core\parseDateFromQuery;
+use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
 
 final class VisitsParams
 {
@@ -36,7 +36,7 @@ final class VisitsParams
     public static function fromRawData(array $query): self
     {
         return new self(
-            new DateRange(parseDateFromQuery($query, 'startDate'), parseDateFromQuery($query, 'endDate')),
+            parseDateRangeFromQuery($query, 'startDate', 'endDate'),
             (int) ($query['page'] ?? 1),
             isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
         );

+ 33 - 0
module/Core/src/Options/UrlShortenerOptions.php

@@ -18,6 +18,9 @@ class UrlShortenerOptions extends AbstractOptions
     private bool $validateUrl = true;
     private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
     private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
+    private bool $autoResolveTitles = false;
+    private bool $anonymizeRemoteAddr = true;
+    private bool $trackOrphanVisits = true;
 
     public function isUrlValidationEnabled(): bool
     {
@@ -55,4 +58,34 @@ class UrlShortenerOptions extends AbstractOptions
             ? $redirectCacheLifetime
             : DEFAULT_REDIRECT_CACHE_LIFETIME;
     }
+
+    public function autoResolveTitles(): bool
+    {
+        return $this->autoResolveTitles;
+    }
+
+    protected function setAutoResolveTitles(bool $autoResolveTitles): void
+    {
+        $this->autoResolveTitles = $autoResolveTitles;
+    }
+
+    public function anonymizeRemoteAddr(): bool
+    {
+        return $this->anonymizeRemoteAddr;
+    }
+
+    protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void
+    {
+        $this->anonymizeRemoteAddr = $anonymizeRemoteAddr;
+    }
+
+    public function trackOrphanVisits(): bool
+    {
+        return $this->trackOrphanVisits;
+    }
+
+    protected function setTrackOrphanVisits(bool $trackOrphanVisits): void
+    {
+        $this->trackOrphanVisits = $trackOrphanVisits;
+    }
 }

+ 2 - 2
module/Core/src/Paginator/Adapter/AbstractCacheableCountPaginatorAdapter.php

@@ -4,13 +4,13 @@ declare(strict_types=1);
 
 namespace Shlinkio\Shlink\Core\Paginator\Adapter;
 
-use Laminas\Paginator\Adapter\AdapterInterface;
+use Pagerfanta\Adapter\AdapterInterface;
 
 abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
 {
     private ?int $count = null;
 
-    final public function count(): int
+    final public function getNbResults(): int
     {
         // Since a new adapter instance is created every time visits are fetched, it is reasonably safe to internally
         // cache the count value.

+ 30 - 0
module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php

@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\Core\Paginator\Adapter;
+
+use Shlinkio\Shlink\Core\Model\VisitsParams;
+use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
+
+class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
+{
+    private VisitRepositoryInterface $repo;
+    private VisitsParams $params;
+
+    public function __construct(VisitRepositoryInterface $repo, VisitsParams $params)
+    {
+        $this->repo = $repo;
+        $this->params = $params;
+    }
+
+    protected function doCount(): int
+    {
+        return $this->repo->countOrphanVisits($this->params->getDateRange());
+    }
+
+    public function getSlice($offset, $length): iterable // phpcs:ignore
+    {
+        return $this->repo->findOrphanVisits($this->params->getDateRange(), $length, $offset);
+    }
+}

+ 4 - 4
module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php

@@ -5,7 +5,7 @@ declare(strict_types=1);
 namespace Shlinkio\Shlink\Core\Paginator\Adapter;
 
 use Happyr\DoctrineSpecification\Specification\Specification;
-use Laminas\Paginator\Adapter\AdapterInterface;
+use Pagerfanta\Adapter\AdapterInterface;
 use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
 use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
 use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -23,10 +23,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
         $this->apiKey = $apiKey;
     }
 
-    public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
+    public function getSlice($offset, $length): array // phpcs:ignore
     {
         return $this->repository->findList(
-            $itemCountPerPage,
+            $length,
             $offset,
             $this->params->searchTerm(),
             $this->params->tags(),
@@ -36,7 +36,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
         );
     }
 
-    public function count(): int
+    public function getNbResults(): int
     {
         return $this->repository->countList(
             $this->params->searchTerm(),

+ 2 - 2
module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php

@@ -28,12 +28,12 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
         $this->apiKey = $apiKey;
     }
 
-    public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
+    public function getSlice($offset, $length): array // phpcs:ignore
     {
         return $this->visitRepository->findVisitsByTag(
             $this->tag,
             $this->params->getDateRange(),
-            $itemCountPerPage,
+            $length,
             $offset,
             $this->resolveSpec(),
         );

+ 2 - 2
module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php

@@ -28,13 +28,13 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
         $this->spec = $spec;
     }
 
-    public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
+    public function getSlice($offset, $length): array // phpcs:ignore
     {
         return $this->visitRepository->findVisitsByShortCode(
             $this->identifier->shortCode(),
             $this->identifier->domain(),
             $this->params->getDateRange(),
-            $itemCountPerPage,
+            $length,
             $offset,
             $this->spec,
         );

+ 7 - 3
module/Core/src/Repository/ShortUrlRepository.php

@@ -55,6 +55,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
         $fieldName = $orderBy->orderField();
         $order = $orderBy->orderDirection();
 
+        // visitsCount and visitCount are deprecated. Only visits should work
         if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
             $qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
                ->leftJoin('s.visits', 'v')
@@ -66,10 +67,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
 
         // Map public field names to column names
         $fieldNameMap = [
-            'originalUrl' => 'longUrl',
+            'originalUrl' => 'longUrl', // Deprecated
             'longUrl' => 'longUrl',
             'shortCode' => 'shortCode',
             'dateCreated' => 'dateCreated',
+            'title' => 'title',
         ];
         if (array_key_exists($fieldName, $fieldNameMap)) {
             $qb->orderBy('s.' . $fieldNameMap[$fieldName], $order);
@@ -120,6 +122,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
                ->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'),
                ))
@@ -201,14 +204,14 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
         return $qb;
     }
 
-    public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl
+    public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl
     {
         $qb = $this->getEntityManager()->createQueryBuilder();
 
         $qb->select('s')
            ->from(ShortUrl::class, 's')
            ->where($qb->expr()->eq('s.longUrl', ':longUrl'))
-           ->setParameter('longUrl', $url)
+           ->setParameter('longUrl', $meta->getLongUrl())
            ->setMaxResults(1)
            ->orderBy('s.id');
 
@@ -239,6 +242,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
             $this->applySpecification($qb, $apiKey->spec(), 's');
         }
 
+        $tags = $meta->getTags();
         $tagsAmount = count($tags);
         if ($tagsAmount === 0) {
             return $qb->getQuery()->getOneOrNullResult();

+ 1 - 1
module/Core/src/Repository/ShortUrlRepositoryInterface.php

@@ -38,7 +38,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat
 
     public function shortCodeIsInUse(string $slug, ?string $domain, ?Specification $spec = null): bool;
 
-    public function findOneMatching(string $url, array $tags, ShortUrlMeta $meta): ?ShortUrl;
+    public function findOneMatching(ShortUrlMeta $meta): ?ShortUrl;
 
     public function importedUrlExists(ImportedShlinkUrl $url): bool;
 }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików